diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-96aff9e.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-96aff9e.json new file mode 100644 index 000000000000..eecef9799cf3 --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-96aff9e.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Added support for @DynamoDbAutoGeneratedTimestampAttribute on attributes within nested objects." +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java index 2ac27d918202..07794d67450e 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java @@ -15,12 +15,21 @@ package software.amazon.awssdk.enhanced.dynamodb.extensions; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.hasMap; +import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.getTableSchemaForListElement; +import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.reconstructCompositeKey; +import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.resolveSchemasPerPath; + import java.time.Clock; import java.time.Instant; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; import software.amazon.awssdk.annotations.NotThreadSafe; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -30,6 +39,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.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -64,10 +74,23 @@ *

* Every time a new update of the record is successfully written to the database, the timestamp at which it was modified will * be automatically updated. This extension applies the conversions as defined in the attribute convertor. + * The implementation handles both flattened nested parameters (identified by keys separated with + * {@code "_NESTED_ATTR_UPDATE_"}) and entire nested maps or lists, ensuring consistent behavior across both representations. + * If a nested object or list is {@code null}, no timestamp values will be generated for any of its annotated fields. + * The same timestamp value is used for both top-level attributes and all applicable nested fields. + * + *

+ * Note: This implementation uses a temporary cache keyed by {@link TableSchema} instance. + * When updating timestamps in nested objects or lists, the correct {@code TableSchema} must be used for each object. + * This cache ensures that each nested object is processed with its own schema, avoiding redundant lookups and ensuring + * all annotated timestamp fields are updated correctly. + *

*/ @SdkPublicApi @ThreadSafe public final class AutoGeneratedTimestampRecordExtension implements DynamoDbEnhancedClientExtension { + + private static final String NESTED_OBJECT_UPDATE = "_NESTED_ATTR_UPDATE_"; private static final String CUSTOM_METADATA_KEY = "AutoGeneratedTimestampExtension:AutoGeneratedTimestampAttribute"; private static final AutoGeneratedTimestampAttribute AUTO_GENERATED_TIMESTAMP_ATTRIBUTE = new AutoGeneratedTimestampAttribute(); @@ -126,26 +149,187 @@ public static AutoGeneratedTimestampRecordExtension create() { */ @Override public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + Map itemToTransform = new HashMap<>(context.items()); + + Map updatedItems = new HashMap<>(); + Instant currentInstant = clock.instant(); - Collection customMetadataObject = context.tableMetadata() - .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); + // Use TableSchema instance as the cache key + Map, TableSchema> schemaInstanceCache = new HashMap<>(); - if (customMetadataObject == null) { + itemToTransform.forEach((key, value) -> { + if (hasMap(value)) { + Optional> nestedSchemaOpt = getNestedSchema(context.tableSchema(), key); + if (nestedSchemaOpt.isPresent()) { + TableSchema nestedSchema = nestedSchemaOpt.get(); + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, nestedSchema); + Map processed = + processNestedObject(value.m(), cachedSchema, currentInstant, schemaInstanceCache); + updatedItems.put(key, AttributeValue.builder().m(processed).build()); + } + } else if (value.hasL() && !value.l().isEmpty()) { + // Check first non-null element to determine if this is a list of maps + AttributeValue firstElement = value.l().stream() + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + + if (hasMap(firstElement)) { + TableSchema elementListSchema = getTableSchemaForListElement(context.tableSchema(), key); + if (elementListSchema != null) { + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema); + Collection updatedList = new ArrayList<>(value.l().size()); + for (AttributeValue listItem : value.l()) { + if (hasMap(listItem)) { + updatedList.add(AttributeValue.builder() + .m(processNestedObject( + listItem.m(), + cachedSchema, + currentInstant, + schemaInstanceCache)) + .build()); + } else { + updatedList.add(listItem); + } + } + updatedItems.put(key, AttributeValue.builder().l(updatedList).build()); + } + } + } + }); + + Map> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema()); + + stringTableSchemaMap.forEach((path, schema) -> { + Collection customMetadataObject = schema.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) + .orElse(null); + + if (customMetadataObject != null) { + customMetadataObject.forEach( + key -> { + AttributeConverter converter = schema.converterForAttribute(key); + if (converter != null) { + insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key), + converter, currentInstant); + } + }); + } + }); + + if (updatedItems.isEmpty()) { return WriteModification.builder().build(); } - Map itemToTransform = new HashMap<>(context.items()); - customMetadataObject.forEach( - key -> insertTimestampInItemToTransform(itemToTransform, key, - context.tableSchema().converterForAttribute(key))); + + itemToTransform.putAll(updatedItems); + return WriteModification.builder() .transformedItem(Collections.unmodifiableMap(itemToTransform)) .build(); } + private static TableSchema getOrCacheSchema( + Map, TableSchema> schemaInstanceCache, TableSchema schema) { + + TableSchema cachedSchema = schemaInstanceCache.get(schema); + if (cachedSchema == null) { + schemaInstanceCache.put(schema, schema); + cachedSchema = schema; + } + return cachedSchema; + } + + private Map processNestedObject(Map nestedMap, TableSchema nestedSchema, + Instant currentInstant, + Map, TableSchema> schemaInstanceCache) { + Map updatedNestedMap = nestedMap; + boolean updated = false; + + Collection customMetadataObject = nestedSchema.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) + .orElse(null); + + if (customMetadataObject != null) { + for (String key : customMetadataObject) { + AttributeConverter converter = nestedSchema.converterForAttribute(key); + if (converter != null) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key), + converter, currentInstant); + } + } + } + + for (Map.Entry entry : nestedMap.entrySet()) { + String nestedKey = entry.getKey(); + AttributeValue nestedValue = entry.getValue(); + if (nestedValue.hasM()) { + Optional> childSchemaOpt = getNestedSchema(nestedSchema, nestedKey); + if (childSchemaOpt.isPresent()) { + TableSchema childSchema = childSchemaOpt.get(); + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, childSchema); + Map processed = processNestedObject( + nestedValue.m(), cachedSchema, currentInstant, schemaInstanceCache); + + if (!Objects.equals(processed, nestedValue.m())) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + updatedNestedMap.put(nestedKey, AttributeValue.builder().m(processed).build()); + } + } + } else if (nestedValue.hasL() && !nestedValue.l().isEmpty()) { + // Check first non-null element to determine if this is a list of maps + AttributeValue firstElement = nestedValue.l().stream() + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + if (hasMap(firstElement)) { + TableSchema listElementSchema = getTableSchemaForListElement(nestedSchema, nestedKey); + if (listElementSchema != null) { + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, listElementSchema); + Collection updatedList = new ArrayList<>(nestedValue.l().size()); + boolean listModified = false; + for (AttributeValue listItem : nestedValue.l()) { + if (hasMap(listItem)) { + AttributeValue updatedItem = AttributeValue.builder() + .m(processNestedObject( + listItem.m(), + cachedSchema, + currentInstant, + schemaInstanceCache)) + .build(); + updatedList.add(updatedItem); + if (!Objects.equals(updatedItem, listItem)) { + listModified = true; + } + } else { + updatedList.add(listItem); + } + } + if (listModified) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build()); + } + } + } + } + } + return updatedNestedMap; + } + private void insertTimestampInItemToTransform(Map itemToTransform, String key, - AttributeConverter converter) { - itemToTransform.put(key, converter.transformFrom(clock.instant())); + AttributeConverter converter, + Instant instant) { + itemToTransform.put(key, converter.transformFrom(instant)); } /** @@ -190,6 +374,7 @@ public void validateType(String attributeName, EnhancedType type, Validate.notNull(type, "type is null"); Validate.notNull(type.rawClass(), "rawClass is null"); Validate.notNull(attributeValueType, "attributeValueType is null"); + validateAttributeName(attributeName); if (!type.rawClass().equals(Instant.class)) { throw new IllegalArgumentException(String.format( @@ -204,5 +389,15 @@ public Consumer modifyMetadata(String attributeName return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, Collections.singleton(attributeName)) .markAttributeAsKey(attributeName, attributeValueType); } + + private static void validateAttributeName(String attributeName) { + if (attributeName.contains(NESTED_OBJECT_UPDATE)) { + throw new IllegalArgumentException( + String.format( + "Attribute name '%s' contains reserved marker '%s' and is not allowed.", + attributeName, + NESTED_OBJECT_UPDATE)); + } + } } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedTimestampAttribute.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedTimestampAttribute.java index 065edfd2a297..3c8e48085001 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedTimestampAttribute.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/annotations/DynamoDbAutoGeneratedTimestampAttribute.java @@ -27,6 +27,10 @@ * Denotes this attribute as recording the auto generated last updated timestamp for the record. * Every time a record with this attribute is written to the database it will update the attribute with current timestamp when * its updated. + *

+ * Note: This annotation must not be applied to fields whose names contain the reserved marker "_NESTED_ATTR_UPDATE_". + * This marker is used internally by the Enhanced Client to represent flattened paths for nested attribute updates. + * If a field name contains this marker, an IllegalArgumentException will be thrown during schema registration. */ @SdkPublicApi @Target({ElementType.METHOD}) diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java index 61d750e98a7e..81477aca1163 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java @@ -28,7 +28,9 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.OperationContext; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -37,6 +39,8 @@ import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity; +import software.amazon.awssdk.utils.CollectionUtils; +import software.amazon.awssdk.utils.StringUtils; @SdkInternalApi public final class EnhancedClientUtils { @@ -204,4 +208,42 @@ public static List getItemsFromSupplier(List> itemSupplierLis public static boolean isNullAttributeValue(AttributeValue attributeValue) { return attributeValue.nul() != null && attributeValue.nul(); } + + public static boolean hasMap(AttributeValue attributeValue) { + return attributeValue != null && attributeValue.hasM(); + } + + /** + * Retrieves the nested {@link TableSchema} for an attribute from the parent schema. + * For parameterized types (e.g., Set, List, Map), extracts the first type parameter's schema. + * + * @param parentSchema the parent schema; must not be null + * @param attributeName the attribute name; must not be null or empty + * @return the nested schema, or empty if unavailable + */ + public static Optional> getNestedSchema(TableSchema parentSchema, CharSequence attributeName) { + if (parentSchema == null) { + throw new IllegalArgumentException("Parent schema cannot be null."); + } + if (StringUtils.isEmpty(attributeName)) { + throw new IllegalArgumentException("Attribute name cannot be null or empty."); + } + + AttributeConverter converter = parentSchema.converterForAttribute(attributeName); + if (converter == null) { + return Optional.empty(); + } + + EnhancedType enhancedType = converter.type(); + if (enhancedType == null) { + return Optional.empty(); + } + + List> rawClassParameters = enhancedType.rawClassParameters(); + if (!CollectionUtils.isNullOrEmpty(rawClassParameters)) { + enhancedType = rawClassParameters.get(0); + } + + return enhancedType.tableSchema(); + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java new file mode 100644 index 000000000000..0cdfa3577922 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java @@ -0,0 +1,168 @@ +/* + * 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.utility; + +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema; +import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.CollectionUtils; +import software.amazon.awssdk.utils.StringUtils; + +@SdkInternalApi +public final class NestedRecordUtils { + + private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); + + private NestedRecordUtils() { + } + + /** + * Resolves and returns the {@link TableSchema} for the element type of list attribute from the provided root schema. + *

+ * This method is useful when dealing with lists of nested objects in a DynamoDB-enhanced table schema, particularly in + * scenarios where the list is part of a flattened nested structure. + *

+ * If the provided key contains the nested object delimiter (e.g., {@code _NESTED_ATTR_UPDATE_}), the method traverses the + * nested hierarchy based on that path to locate the correct schema for the target attribute. Otherwise, it directly resolves + * the list element type from the root schema using reflection. + * + * @param rootSchema The root {@link TableSchema} representing the top-level entity. + * @param key The key representing the list attribute, either flat or nested (using a delimiter). + * @return The {@link TableSchema} representing the list element type of the specified attribute. + * @throws IllegalArgumentException If the list element class cannot be found via reflection. + */ + public static TableSchema getTableSchemaForListElement(TableSchema rootSchema, String key) { + TableSchema listElementSchema; + try { + if (!key.contains(NESTED_OBJECT_UPDATE)) { + Optional> staticSchema = getNestedSchema(rootSchema, key); + if (staticSchema.isPresent()) { + listElementSchema = staticSchema.get(); + } else { + AttributeConverter converter = rootSchema.converterForAttribute(key); + if (converter == null) { + throw new IllegalArgumentException("No converter found for attribute: " + key); + } + List> rawClassParameters = converter.type().rawClassParameters(); + if (CollectionUtils.isNullOrEmpty(rawClassParameters)) { + throw new IllegalArgumentException("No type parameters found for list attribute: " + key); + } + listElementSchema = TableSchema.fromClass( + Class.forName(rawClassParameters.get(0).rawClass().getName())); + } + + } else { + String[] parts = NESTED_OBJECT_PATTERN.split(key); + TableSchema currentSchema = rootSchema; + + for (int i = 0; i < parts.length - 1; i++) { + Optional> nestedSchema = getNestedSchema(currentSchema, parts[i]); + if (nestedSchema.isPresent()) { + currentSchema = nestedSchema.get(); + } + } + String attributeName = parts[parts.length - 1]; + Optional> nestedListSchema = getNestedSchema(currentSchema, attributeName); + listElementSchema = nestedListSchema + .orElseThrow(() -> new IllegalArgumentException("Unable to resolve schema for list element at: " + key)); + } + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException("Class not found for field name: " + key, e); + } + return listElementSchema; + } + + /** + * Traverses the attribute keys representing flattened nested structures and resolves the corresponding {@link TableSchema} + * for each nested path. + *

+ * The method constructs a mapping between each unique nested path (represented as dot-delimited strings) and the + * corresponding {@link TableSchema} object derived from the root schema. It supports resolving schemas for arbitrarily deep + * nesting, using the {@code _NESTED_ATTR_UPDATE_} pattern as a path delimiter. + *

+ * This is typically used in update or transformation flows where fields from nested objects are represented as flattened keys + * in the attribute map (e.g., {@code parent_NESTED_ATTR_UPDATE_child}). + * + * @param attributesToSet A map of flattened attribute keys to values, where keys may represent paths to nested attributes. + * @param rootSchema The root {@link TableSchema} of the top-level entity. + * @return A map where the key is the nested path (e.g., {@code "parent.child"}) and the value is the {@link TableSchema} + * corresponding to that level in the object hierarchy. + */ + public static Map> resolveSchemasPerPath(Map attributesToSet, + TableSchema rootSchema) { + Map> schemaMap = new HashMap<>(); + schemaMap.put("", rootSchema); + + for (String key : attributesToSet.keySet()) { + String[] parts = NESTED_OBJECT_PATTERN.split(key); + + StringBuilder pathBuilder = new StringBuilder(); + TableSchema currentSchema = rootSchema; + + for (int i = 0; i < parts.length - 1; i++) { + if (pathBuilder.length() > 0) { + pathBuilder.append("."); + } + pathBuilder.append(parts[i]); + + String path = pathBuilder.toString(); + + if (!schemaMap.containsKey(path)) { + Optional> nestedSchema = getNestedSchema(currentSchema, parts[i]); + if (nestedSchema.isPresent()) { + schemaMap.put(path, nestedSchema.get()); + currentSchema = nestedSchema.get(); + } + } else { + currentSchema = schemaMap.get(path); + } + } + } + return schemaMap; + } + + /** + * Converts a dot-separated path to a composite key using nested object delimiters. Example: + * {@code reconstructCompositeKey("parent.child", "attr")} returns + * {@code "parent_NESTED_ATTR_UPDATE_child_NESTED_ATTR_UPDATE_attr"} + * + * @param path the dot-separated path; may be null or empty + * @param attributeName the attribute name to append; must not be null + * @return the composite key with nested object delimiters + */ + public static String reconstructCompositeKey(String path, String attributeName) { + if (attributeName == null) { + throw new IllegalArgumentException("Attribute name cannot be null"); + } + + if (StringUtils.isEmpty(path)) { + return attributeName; + } + + return String.join(NESTED_OBJECT_UPDATE, path.split("\\.")) + + NESTED_OBJECT_UPDATE + attributeName; + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java new file mode 100644 index 000000000000..1b9d5ef1b45e --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java @@ -0,0 +1,1681 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension.AttributeTags.autoGeneratedTimestampAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleBeanChild; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleImmutableChild; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.buildStaticImmutableSchemaForNestedRecordWithList; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.buildStaticImmutableSchemaForSimpleRecordWithList; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.buildStaticSchemaForNestedRecordWithList; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.buildStaticSchemaForSimpleRecordWithList; +import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.updateBehavior; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.Mockito; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.converters.EpochMillisFormatTestConverter; +import software.amazon.awssdk.enhanced.dynamodb.converters.TimeFormatUpdateTestConverter; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidNestedAttributeName; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidNestedAttributeName.BeanWithInvalidNestedAttributeNameChild; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidRootAttributeName; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedBeanChild; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedBeanWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedImmutableChildRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedImmutableRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedStaticChildRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedStaticRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleBeanWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleBeanWithMap; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleBeanWithSet; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleImmutableRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleImmutableRecordWithMap; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleImmutableRecordWithSet; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.SimpleStaticRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.ImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; +import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; + +public class AutoGeneratedTimestampExtensionTest extends LocalDynamoDbSyncTestBase { + + private static final Instant MOCKED_INSTANT_NOW = Instant.now(Clock.fixed(Instant.parse("2019-01-13T14:00:00Z"), + ZoneOffset.UTC)); + + private static final Instant MOCKED_INSTANT_UPDATE_ONE = Instant.now(Clock.fixed(Instant.parse("2019-01-14T14:00:00Z"), + ZoneOffset.UTC)); + + private static final Instant MOCKED_INSTANT_UPDATE_TWO = Instant.now(Clock.fixed(Instant.parse("2019-01-15T14:00:00Z"), + ZoneOffset.UTC)); + + private final Clock mockClock = Mockito.mock(Clock.class); + private final DynamoDbEnhancedClient enhancedClient = + DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedTimestampRecordExtension.builder().baseClock(mockClock).build()) + .build(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Before + public void setup() { + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_NOW); + } + + @After + public void cleanup() { + // Tables are cleaned up by individual tests + } + + @Test + public void putNewRecord_setsInitialTimestamps() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + BasicRecord item = new BasicRecord() + .setId("id") + .setAttribute("one"); + + table.putItem(r -> r.item(item)); + BasicRecord result = table.getItem(r -> r.key(k -> k.partitionValue("id"))); + + BasicRecord expected = new BasicRecord() + .setId("id") + .setAttribute("one") + .setLastUpdatedDate(MOCKED_INSTANT_NOW) + .setCreatedDate(MOCKED_INSTANT_NOW) + .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_NOW) + .setConvertedLastUpdatedDate(MOCKED_INSTANT_NOW) + .setFlattenedRecord(new FlattenedRecord().setGenerated(MOCKED_INSTANT_NOW)); + + assertThat(result, is(expected)); + + // Verify converted format is stored correctly + GetItemResponse stored = getItemFromDDB(table.tableName(), "id"); + assertThat(stored.item().get("convertedLastUpdatedDate").s(), is("13 01 2019 14:00:00")); + + table.deleteTable(); + } + + @Test + public void updateNewRecord_setsAutoFormattedTimestamps() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + BasicRecord result = table.updateItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + BasicRecord expected = new BasicRecord() + .setId("id") + .setAttribute("one") + .setLastUpdatedDate(MOCKED_INSTANT_NOW) + .setCreatedDate(MOCKED_INSTANT_NOW) + .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_NOW) + .setConvertedLastUpdatedDate(MOCKED_INSTANT_NOW) + .setFlattenedRecord(new FlattenedRecord().setGenerated(MOCKED_INSTANT_NOW)); + + assertThat(result, is(expected)); + + GetItemResponse stored = getItemFromDDB(table.tableName(), "id"); + assertThat(stored.item().get("convertedLastUpdatedDate").s(), is("13 01 2019 14:00:00")); + + table.deleteTable(); + } + + @Test + public void putExistingRecord_updatesTimestamps() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Initial put + table.putItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + BasicRecord initial = table.getItem(r -> r.key(k -> k.partitionValue("id"))); + assertThat(initial.getCreatedDate(), is(MOCKED_INSTANT_NOW)); + assertThat(initial.getLastUpdatedDate(), is(MOCKED_INSTANT_NOW)); + + // Update with new timestamp + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); + table.putItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + BasicRecord updated = table.getItem(r -> r.key(k -> k.partitionValue("id"))); + // Note: PutItem updates both created and last updated dates + assertThat(updated.getCreatedDate(), is(MOCKED_INSTANT_UPDATE_ONE)); + assertThat(updated.getLastUpdatedDate(), is(MOCKED_INSTANT_UPDATE_ONE)); + + GetItemResponse stored = getItemFromDDB(table.tableName(), "id"); + assertThat(stored.item().get("convertedLastUpdatedDate").s(), is("14 01 2019 14:00:00")); + + table.deleteTable(); + } + + @Test + public void updateExistingRecord_preservesCreatedDate() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Initial put + table.putItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + // Update with new timestamp + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); + BasicRecord result = table.updateItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + // UpdateItem preserves created date but updates last updated date + assertThat(result.getCreatedDate(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLastUpdatedDate(), is(MOCKED_INSTANT_UPDATE_ONE)); + + table.deleteTable(); + } + + @Test + public void multipleUpdates_updatesTimestampsCorrectly() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Initial put + table.putItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + // First update + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); + BasicRecord firstUpdate = table.updateItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + assertThat(firstUpdate.getCreatedDate(), is(MOCKED_INSTANT_NOW)); + assertThat(firstUpdate.getLastUpdatedDate(), is(MOCKED_INSTANT_UPDATE_ONE)); + + // Second update + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_TWO); + BasicRecord secondUpdate = table.updateItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + assertThat(secondUpdate.getCreatedDate(), is(MOCKED_INSTANT_NOW)); + assertThat(secondUpdate.getLastUpdatedDate(), is(MOCKED_INSTANT_UPDATE_TWO)); + + // Verify epoch millis format + GetItemResponse stored = getItemFromDDB(table.tableName(), "id"); + assertThat(Long.parseLong(stored.item().get("lastUpdatedDateInEpochMillis").n()), + is(MOCKED_INSTANT_UPDATE_TWO.toEpochMilli())); + + table.deleteTable(); + } + + @Test + public void putWithConditionExpression_updatesTimestampsWhenConditionMet() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + table.putItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + Expression conditionExpression = Expression.builder() + .expression("#k = :v OR #k = :v1") + .putExpressionName("#k", "attribute") + .putExpressionValue(":v", stringValue("one")) + .putExpressionValue(":v1", stringValue("wrong2")) + .build(); + + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); + table.putItem(PutItemEnhancedRequest.builder(BasicRecord.class) + .item(new BasicRecord().setId("id").setAttribute("one")) + .conditionExpression(conditionExpression) + .build()); + + BasicRecord result = table.getItem(r -> r.key(k -> k.partitionValue("id"))); + assertThat(result.getLastUpdatedDate(), is(MOCKED_INSTANT_UPDATE_ONE)); + assertThat(result.getCreatedDate(), is(MOCKED_INSTANT_UPDATE_ONE)); // PutItem updates both + + table.deleteTable(); + } + + @Test + public void updateWithConditionExpression_updatesTimestampsWhenConditionMet() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + table.updateItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + Expression conditionExpression = Expression.builder() + .expression("#k = :v OR #k = :v1") + .putExpressionName("#k", "attribute") + .putExpressionValue(":v", stringValue("one")) + .putExpressionValue(":v1", stringValue("wrong2")) + .build(); + + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); + BasicRecord result = table.updateItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one")) + .conditionExpression(conditionExpression)); + + assertThat(result.getLastUpdatedDate(), is(MOCKED_INSTANT_UPDATE_ONE)); + assertThat(result.getCreatedDate(), is(MOCKED_INSTANT_NOW)); // UpdateItem preserves created date + + table.deleteTable(); + } + + @Test + public void putWithFailedCondition_throwsException() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + table.putItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + Expression conditionExpression = Expression.builder() + .expression("#k = :v OR #k = :v1") + .putExpressionName("#k", "attribute") + .putExpressionValue(":v", stringValue("wrong1")) + .putExpressionValue(":v1", stringValue("wrong2")) + .build(); + + thrown.expect(ConditionalCheckFailedException.class); + table.putItem(PutItemEnhancedRequest.builder(BasicRecord.class) + .item(new BasicRecord().setId("id").setAttribute("one")) + .conditionExpression(conditionExpression) + .build()); + + table.deleteTable(); + } + + @Test + public void updateWithFailedCondition_throwsException() { + String tableName = getConcreteTableName("basic-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createBasicRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + table.updateItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one"))); + + Expression conditionExpression = Expression.builder() + .expression("#k = :v OR #k = :v1") + .putExpressionName("#k", "attribute") + .putExpressionValue(":v", stringValue("wrong1")) + .putExpressionValue(":v1", stringValue("wrong2")) + .build(); + + thrown.expect(ConditionalCheckFailedException.class); + table.updateItem(r -> r.item(new BasicRecord().setId("id").setAttribute("one")) + .conditionExpression(conditionExpression)); + + table.deleteTable(); + } + + @Test + public void putNewRecord_setsTimestampsOnAlNestedLevels() { + String tableName = getConcreteTableName("nested-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createNestedRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + NestedBeanChild nestedLevel1 = new NestedBeanChild(); + + NestedRecord item = new NestedRecord() + .setId("id") + .setAttribute("one") + .setNestedRecord(nestedLevel1); + + table.putItem(r -> r.item(item)); + NestedRecord result = table.getItem(r -> r.key(k -> k.partitionValue("id"))); + + // Verify nested level has timestamp set + assertThat(result.getNestedRecord().getTime(), is(MOCKED_INSTANT_NOW)); + + // Verify in DDB storage + GetItemResponse stored = getItemFromDDB(table.tableName(), "id"); + Map lvl1Map = stored.item().get("nestedRecord").m(); + assertThat(lvl1Map.get("time").s(), is(MOCKED_INSTANT_NOW.toString())); + + table.deleteTable(); + } + + @Test + public void updateNestedRecord_updatesTimestampsOnAllLevels() { + String tableName = getConcreteTableName("nested-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createNestedRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Initial put + table.putItem(r -> r.item(new NestedRecord().setId("id").setAttribute("one") + .setNestedRecord(new NestedBeanChild()))); + + // First update + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); + table.updateItem(r -> r.item(new NestedRecord().setId("id").setAttribute("one") + .setNestedRecord(new NestedBeanChild()))); + + GetItemResponse stored = getItemFromDDB(table.tableName(), "id"); + assertThat(stored.item().get("nestedRecord").m().get("time").s(), is(MOCKED_INSTANT_UPDATE_ONE.toString())); + + // Second update + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_TWO); + table.updateItem(r -> r.item(new NestedRecord().setId("id").setAttribute("one") + .setNestedRecord(new NestedBeanChild()))); + + stored = getItemFromDDB(table.tableName(), "id"); + assertThat(stored.item().get("nestedRecord").m().get("time").s(), is(MOCKED_INSTANT_UPDATE_TWO.toString())); + + table.deleteTable(); + } + + @Test + public void recursiveRecord_allTimestampsAreUpdated() { + String tableName = getConcreteTableName("recursive-record-table"); + DynamoDbTable table = enhancedClient.table(tableName, createRecursiveRecordLevel1Schema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + RecursiveRecord level3 = new RecursiveRecord() + .setId("l3_id"); + RecursiveRecord level2 = new RecursiveRecord() + .setId("l2_id") + .setChild(level3); + RecursiveRecord level1 = new RecursiveRecord() + .setId("l1_id") + .setChild(level2); + + table.putItem(level1); + + GetItemResponse response = getItemFromDDB(table.tableName(), "l1_id"); + Map item = response.item(); + + // Assert l1 timestamp is set + assertNotNull(item.get("parentTimestamp")); + assertEquals(MOCKED_INSTANT_NOW.toString(), item.get("parentTimestamp").s()); + + // Assert l2 timestamp is set + Map childMap = item.get("child").m(); + assertNotNull(childMap.get("childTimestamp")); + assertEquals(MOCKED_INSTANT_NOW.toString(), childMap.get("childTimestamp").s()); + + // Assert l3 timestamp is set + Map grandchildMap = childMap.get("child").m(); + assertNotNull(grandchildMap.get("grandchildTimestamp")); + assertEquals(MOCKED_INSTANT_NOW.toString(), grandchildMap.get("grandchildTimestamp").s()); + + table.deleteTable(); + } + + @Test + public void beanSchema_simpleRecordWithList_populatesTimestamps() { + String tableName = getConcreteTableName("bean-simple-list-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + table.putItem( + new SimpleBeanWithList() + .setId("1") + .setChildList(Arrays.asList( + new SimpleBeanChild().setId("child1"), + new SimpleBeanChild().setId("child2"))) + .setChildStringList(Collections.singletonList("test"))); + + SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList().get(0).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList().get(1).getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void beanSchema_simpleRecordWithSet_populatesTimestamps() { + String tableName = getConcreteTableName("bean-simple-set-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithSet.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + table.putItem( + new SimpleBeanWithSet() + .setId("1") + .setChildSet(new HashSet<>(Arrays.asList("child1", "child2")))); + + SimpleBeanWithSet result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildSet(), hasSize(2)); + assertThat(result.getChildSet().contains("child1"), is(true)); + assertThat(result.getChildSet().contains("child2"), is(true)); + + table.deleteTable(); + } + + @Test + public void beanSchema_simpleRecordWithMap_populatesTimestamps() { + String tableName = getConcreteTableName("bean-simple-map-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithMap.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem( + new SimpleBeanWithMap() + .setId("1") + .setChildMap(new HashMap() {{ + put("child1", "attr_child1"); + put("child2", "attr_child2"); + }})); + + SimpleBeanWithMap result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildMap().size(), is(2)); + assertThat(result.getChildMap().get("child1"), is("attr_child1")); + assertThat(result.getChildMap().get("child2"), is("attr_child2")); + + table.deleteTable(); + } + + @Test + public void beanSchema_nestedRecordWithList_populatesTimestamps() { + String tableName = getConcreteTableName("bean-nested-list-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(NestedBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem( + new NestedBeanWithList() + .setId("1") + .setLevel2(new NestedBeanChild())); + + NestedBeanWithList level1 = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(level1.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(level1.getLevel2().getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void immutableSchema_simpleRecordWithList_populatesTimestamps() { + String tableName = getConcreteTableName("immutable-simple-list-table"); + DynamoDbTable table = + enhancedClient.table(tableName, ImmutableTableSchema.create(SimpleImmutableRecordWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem( + SimpleImmutableRecordWithList + .builder() + .id("1") + .childList(Arrays.asList( + SimpleImmutableChild.builder().id("child1").build(), + SimpleImmutableChild.builder().id("child2").build())) + .build()); + + SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList().size(), is(2)); + assertThat(result.getChildList().get(0).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList().get(1).getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void immutableSchema_simpleRecordWithSet_populatesTimestamps() { + String tableName = getConcreteTableName("immutable-simple-set-table"); + DynamoDbTable table = + enhancedClient.table(tableName, ImmutableTableSchema.create(SimpleImmutableRecordWithSet.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem( + SimpleImmutableRecordWithSet + .builder() + .id("1") + .childSet(new HashSet<>(Arrays.asList("child1", "child2"))) + .build()); + + SimpleImmutableRecordWithSet result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildSet(), hasSize(2)); + assertThat(result.getChildSet().contains("child1"), is(true)); + assertThat(result.getChildSet().contains("child2"), is(true)); + + table.deleteTable(); + } + + @Test + public void immutableSchema_simpleRecordWithMap_populatesTimestamps() { + String tableName = getConcreteTableName("immutable-simple-map-table"); + DynamoDbTable table = + enhancedClient.table(tableName, ImmutableTableSchema.create(SimpleImmutableRecordWithMap.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem(SimpleImmutableRecordWithMap.builder() + .id("1") + .childMap(new HashMap() {{ + put("child1", "attr_child1"); + put("child2", "attr_child2"); + }}) + .build()); + + SimpleImmutableRecordWithMap result = table.getItem(r -> r.key(k -> k.partitionValue( + "1"))); + + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertNotNull(result.getChildMap()); + assertThat(result.getChildMap().size(), is(2)); + assertThat(result.getChildMap().get("child1"), is("attr_child1")); + assertThat(result.getChildMap().get("child2"), is("attr_child2")); + + table.deleteTable(); + } + + @Test + public void immutableSchema_nestedRecordWithList_populatesTimestamps() { + String tableName = getConcreteTableName("immutable-nested-list-table"); + DynamoDbTable table = + enhancedClient.table(tableName, ImmutableTableSchema.create(NestedImmutableRecordWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem( + NestedImmutableRecordWithList + .builder() + .id("1") + .level2(NestedImmutableChildRecordWithList.builder().build()) + .build() + ); + + NestedImmutableRecordWithList level1 = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(level1.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(level1.getLevel2().getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void staticSchema_simpleRecordWithList_populatesTimestamps() { + String tableName = getConcreteTableName("static-simple-list-table"); + TableSchema schema = buildStaticSchemaForSimpleRecordWithList(); + DynamoDbTable table = enhancedClient.table(tableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + table.putItem( + new SimpleStaticRecordWithList() + .setId("1")); + + SimpleStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void staticSchema_nestedRecordWithList_populatesTimestamps() { + String tableName = getConcreteTableName("static-nested-list-table"); + TableSchema schema = buildStaticSchemaForNestedRecordWithList(); + DynamoDbTable table = enhancedClient.table(tableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + table.putItem( + new NestedStaticRecordWithList() + .setId("1") + .setLevel2(new NestedStaticChildRecordWithList())); + + NestedStaticRecordWithList level1 = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(level1.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(level1.getLevel2().getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void staticImmutableSchema_simpleRecordWithList_populatesTimestamps() { + String tableName = getConcreteTableName("static-immutable-simple-list-table"); + TableSchema schema = buildStaticImmutableSchemaForSimpleRecordWithList(); + DynamoDbTable table = enhancedClient.table(tableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem( + SimpleImmutableRecordWithList + .builder() + .id("1") + .childList(Arrays.asList( + SimpleImmutableChild.builder().id("child1").build(), + SimpleImmutableChild.builder().id("child2").build())) + .build()); + + SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertNotNull(result.getChildList()); + assertThat(result.getChildList().size(), is(2)); + assertThat(result.getChildList().get(0).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList().get(1).getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void staticImmutableSchema_nestedRecordWithList_populatesTimestamps() { + String tableName = getConcreteTableName("static-immutable-nested-list-table"); + TableSchema schema = buildStaticImmutableSchemaForNestedRecordWithList(); + DynamoDbTable table = enhancedClient.table(tableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem( + NestedImmutableRecordWithList + .builder() + .id("1") + .level2(NestedImmutableChildRecordWithList.builder().build()) + .build()); + + NestedImmutableRecordWithList level1 = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + assertThat(level1.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(level1.getLevel2().getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void autogenerateTimestamps_onNonInstantAttribute_throwsException() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Attribute 'lastUpdatedDate' of Class type class java.lang.String is not a suitable " + + "Java Class type to be used as a Auto Generated Timestamp attribute. Only java.time." + + "Instant Class type is supported."); + + StaticTableSchema.builder(RecordWithStringUpdateDate.class) + .newItemSupplier(RecordWithStringUpdateDate::new) + .addAttribute(String.class, a -> a.name("id") + .getter(RecordWithStringUpdateDate::getId) + .setter(RecordWithStringUpdateDate::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("lastUpdatedDate") + .getter(RecordWithStringUpdateDate::getLastUpdatedDate) + .setter(RecordWithStringUpdateDate::setLastUpdatedDate) + .tags(autoGeneratedTimestampAttribute())) + .build(); + } + + @Test + public void autogenerateTimestamps_onRootAttributeWithReservedMarker_throwsException() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Attribute name 'attr_NESTED_ATTR_UPDATE_' contains reserved marker " + + "'_NESTED_ATTR_UPDATE_' and is not allowed."); + + StaticTableSchema + .builder(BeanWithInvalidRootAttributeName.class) + .newItemSupplier(BeanWithInvalidRootAttributeName::new) + .addAttribute(String.class, + a -> a.name("id") + .getter(BeanWithInvalidRootAttributeName::getId) + .setter(BeanWithInvalidRootAttributeName::setId) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, + a -> a.name("attr_NESTED_ATTR_UPDATE_") + .getter(BeanWithInvalidRootAttributeName::getAttr_NESTED_ATTR_UPDATE_) + .setter(BeanWithInvalidRootAttributeName::setAttr_NESTED_ATTR_UPDATE_) + .tags(autoGeneratedTimestampAttribute())) + .build(); + } + + @Test + public void autogenerateTimestamps_onNestedAttributeWithReservedMarker_throwsException() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Attribute name 'childAttr_NESTED_ATTR_UPDATE_' contains reserved marker " + + "'_NESTED_ATTR_UPDATE_' and is not allowed."); + + StaticTableSchema + .builder(BeanWithInvalidNestedAttributeName.class) + .newItemSupplier(BeanWithInvalidNestedAttributeName::new) + .addAttribute( + String.class, + a -> a.name("id") + .getter(BeanWithInvalidNestedAttributeName::getId) + .setter(BeanWithInvalidNestedAttributeName::setId) + .tags(primaryPartitionKey())) + .addAttribute( + EnhancedType.documentOf( + BeanWithInvalidNestedAttributeNameChild.class, + StaticTableSchema + .builder(BeanWithInvalidNestedAttributeNameChild.class) + .newItemSupplier(BeanWithInvalidNestedAttributeNameChild::new) + .addAttribute(Instant.class, + a -> a.name("childAttr_NESTED_ATTR_UPDATE_") + .getter(BeanWithInvalidNestedAttributeNameChild::getAttr_NESTED_ATTR_UPDATE_) + .setter(BeanWithInvalidNestedAttributeNameChild::setAttr_NESTED_ATTR_UPDATE_) + .tags(autoGeneratedTimestampAttribute())) + .build()), + a -> a.name("nestedChildAttribute") + .getter(BeanWithInvalidNestedAttributeName::getNestedChildAttribute) + .setter(BeanWithInvalidNestedAttributeName::setNestedChildAttribute)) + .build(); + } + + @Test + public void extension_create_returnsExtensionWithSystemClock() { + AutoGeneratedTimestampRecordExtension extension = AutoGeneratedTimestampRecordExtension.create(); + + assertThat(extension, is(notNullValue())); + } + + @Test + public void extension_builder_returnsBuilderInstance() { + AutoGeneratedTimestampRecordExtension.Builder builder = AutoGeneratedTimestampRecordExtension.builder(); + + assertThat(builder, is(notNullValue())); + } + + @Test + public void extension_builderWithCustomClock_usesCustomClock() { + Clock customClock = Clock.fixed(MOCKED_INSTANT_NOW, ZoneOffset.UTC); + AutoGeneratedTimestampRecordExtension extension = AutoGeneratedTimestampRecordExtension.builder() + .baseClock(customClock) + .build(); + + assertThat(extension, is(notNullValue())); + } + + @Test + public void extension_toBuilder_returnsBuilderWithExistingValues() { + Clock customClock = Clock.fixed(MOCKED_INSTANT_NOW, ZoneOffset.UTC); + AutoGeneratedTimestampRecordExtension extension = AutoGeneratedTimestampRecordExtension.builder() + .baseClock(customClock) + .build(); + + AutoGeneratedTimestampRecordExtension.Builder builder = extension.toBuilder(); + + assertThat(builder, is(notNullValue())); + assertThat(builder.build(), is(notNullValue())); + } + + @Test + public void attributeTags_autoGeneratedTimestampAttribute_returnsStaticAttributeTag() { + assertThat(autoGeneratedTimestampAttribute(), + is(notNullValue())); + } + + @Test + public void beforeWrite_withNullNestedObject_skipsProcessing() { + String tableName = getConcreteTableName("null-nested-table"); + DynamoDbTable table = enhancedClient.table(tableName, createNestedRecordSchema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Put item with null nested object + NestedRecord item = new NestedRecord() + .setId("id") + .setAttribute("test") + .setNestedRecord(null); // null nested object + + table.putItem(r -> r.item(item)); + NestedRecord result = table.getItem(r -> r.key(k -> k.partitionValue("id"))); + + // Root timestamps should be set, nested should be null + assertThat(result.getLastUpdatedDate(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getCreatedDate(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getNestedRecord(), is(nullValue())); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withEmptyListAttribute_skipsListProcessing() { + String tableName = getConcreteTableName("empty-list-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Put item with empty list + SimpleBeanWithList item = new SimpleBeanWithList() + .setId("1") + .setChildList(Collections.emptyList()); // empty list + + table.putItem(r -> r.item(item)); + SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Root timestamp should be set, list should be empty + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList(), hasSize(0)); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withListContainingNullElements_handlesNullsGracefully() { + String tableName = getConcreteTableName("list-with-nulls-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Create list with null elements mixed with valid elements + List listWithNulls = new ArrayList<>(); + listWithNulls.add(null); + listWithNulls.add(new SimpleBeanChild().setId("child1")); + listWithNulls.add(null); + listWithNulls.add(new SimpleBeanChild().setId("child2")); + + SimpleBeanWithList item = new SimpleBeanWithList() + .setId("1") + .setChildList(listWithNulls); + + table.putItem(r -> r.item(item)); + SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Root timestamp should be set + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList(), hasSize(4)); + + // Non-null elements should have timestamps + assertThat(result.getChildList().get(1).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList().get(3).getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withDeepNestedStructure_updatesAllLevels() { + String tableName = getConcreteTableName("deep-nested-table"); + DynamoDbTable table = enhancedClient.table(tableName, createRecursiveRecordLevel1Schema()); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Create deeply nested structure + RecursiveRecord level4 = new RecursiveRecord().setId("l4_id"); + RecursiveRecord level3 = new RecursiveRecord().setId("l3_id").setChild(level4); + RecursiveRecord level2 = new RecursiveRecord().setId("l2_id").setChild(level3); + RecursiveRecord level1 = new RecursiveRecord().setId("l1_id").setChild(level2); + + table.putItem(level1); + + GetItemResponse response = getItemFromDDB(table.tableName(), "l1_id"); + Map item = response.item(); + + // Verify all levels have timestamps + assertThat(item.get("parentTimestamp").s(), is(MOCKED_INSTANT_NOW.toString())); + + Map level2Map = item.get("child").m(); + assertThat(level2Map.get("childTimestamp").s(), is(MOCKED_INSTANT_NOW.toString())); + + Map level3Map = level2Map.get("child").m(); + assertThat(level3Map.get("grandchildTimestamp").s(), is(MOCKED_INSTANT_NOW.toString())); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withNestedListOfMaps_updatesTimestampsInListElements() { + String tableName = getConcreteTableName("nested-list-of-maps-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Create item with list of child objects (maps) + SimpleBeanWithList item = new SimpleBeanWithList() + .setId("1") + .setChildList(Arrays.asList( + new SimpleBeanChild().setId("child1"), + new SimpleBeanChild().setId("child2"), + new SimpleBeanChild().setId("child3") + )); + + table.putItem(r -> r.item(item)); + SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Verify timestamps at root and in all list elements + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList(), hasSize(3)); + assertThat(result.getChildList().get(0).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList().get(1).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList().get(2).getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withNestedListOfNonMapElements_skipsListProcessing() { + String tableName = getConcreteTableName("nested-list-of-strings-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithSet.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Create item with set of strings (not maps) - should skip nested processing + SimpleBeanWithSet item = new SimpleBeanWithSet() + .setId("1") + .setChildSet(new HashSet<>(Arrays.asList("string1", "string2", "string3"))); + + table.putItem(r -> r.item(item)); + SimpleBeanWithSet result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Verify root timestamp is set, set elements are preserved + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildSet(), hasSize(3)); + assertThat(result.getChildSet().contains("string1"), is(true)); + assertThat(result.getChildSet().contains("string2"), is(true)); + assertThat(result.getChildSet().contains("string3"), is(true)); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withMultipleNestedListUpdates_updatesAllTimestamps() { + String tableName = getConcreteTableName("multiple-nested-list-updates-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Initial put + table.putItem(new SimpleBeanWithList() + .setId("1") + .setChildList(Arrays.asList( + new SimpleBeanChild().setId("child1"), + new SimpleBeanChild().setId("child2") + ))); + + // First update with new timestamp + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); + table.updateItem(new SimpleBeanWithList() + .setId("1") + .setChildList(Arrays.asList( + new SimpleBeanChild().setId("child1_updated"), + new SimpleBeanChild().setId("child2_updated"), + new SimpleBeanChild().setId("child3_new") + ))); + + SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Verify all timestamps are updated to the new time + assertThat(result.getTime(), is(MOCKED_INSTANT_UPDATE_ONE)); + assertThat(result.getChildList(), hasSize(3)); + assertThat(result.getChildList().get(0).getTime(), is(MOCKED_INSTANT_UPDATE_ONE)); + assertThat(result.getChildList().get(1).getTime(), is(MOCKED_INSTANT_UPDATE_ONE)); + assertThat(result.getChildList().get(2).getTime(), is(MOCKED_INSTANT_UPDATE_ONE)); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withEmptyNestedList_skipsNestedListProcessing() { + String tableName = getConcreteTableName("empty-nested-list-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(SimpleBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Create item with empty list + SimpleBeanWithList item = new SimpleBeanWithList() + .setId("1") + .setChildList(Collections.emptyList()); + + table.putItem(r -> r.item(item)); + SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Verify root timestamp is set, empty list is preserved + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getChildList(), hasSize(0)); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withNestedObjectContainingListOfMaps_updatesTimestampsInNestedList() { + String tableName = getConcreteTableName("nested-object-with-list-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(NestedBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Create nested structure: root -> nested object -> list of child objects + // This tests the branch at lines 284-318 in AutoGeneratedTimestampRecordExtension + NestedBeanChild nestedChild = new NestedBeanChild(); + nestedChild.setChildList(Arrays.asList( + new SimpleBeanChild().setId("nested_child1"), + new SimpleBeanChild().setId("nested_child2"), + new SimpleBeanChild().setId("nested_child3") + )); + + NestedBeanWithList item = new NestedBeanWithList() + .setId("1") + .setLevel2(nestedChild); + + table.putItem(r -> r.item(item)); + NestedBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Verify timestamps at all levels: root, nested object, and nested list elements + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getChildList(), hasSize(3)); + assertThat(result.getLevel2().getChildList().get(0).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getChildList().get(1).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getChildList().get(2).getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withNestedObjectContainingListWithNulls_updatesTimestamps() { + String tableName = getConcreteTableName("nested-list-with-nulls-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(NestedBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Create nested structure with null elements in the list + List listWithNulls = new ArrayList<>(); + listWithNulls.add(new SimpleBeanChild().setId("child1")); + listWithNulls.add(null); + listWithNulls.add(new SimpleBeanChild().setId("child2")); + listWithNulls.add(null); + + NestedBeanChild nestedChild = new NestedBeanChild(); + nestedChild.setChildList(listWithNulls); + + NestedBeanWithList item = new NestedBeanWithList() + .setId("1") + .setLevel2(nestedChild); + + table.putItem(r -> r.item(item)); + NestedBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Verify timestamps are set for non-null elements, nulls are preserved + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getChildList(), hasSize(4)); + assertThat(result.getLevel2().getChildList().get(0).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getChildList().get(1).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getChildList().get(2).getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getChildList().get(3).getTime(), is(MOCKED_INSTANT_NOW)); + + table.deleteTable(); + } + + @Test + public void beforeWrite_withNestedObjectContainingEmptyList_skipsListProcessing() { + String tableName = getConcreteTableName("nested-empty-list-table"); + DynamoDbTable table = + enhancedClient.table(tableName, BeanTableSchema.create(NestedBeanWithList.class)); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + // Create nested structure with empty list + NestedBeanChild nestedChild = new NestedBeanChild(); + nestedChild.setChildList(Collections.emptyList()); + + NestedBeanWithList item = new NestedBeanWithList() + .setId("1") + .setLevel2(nestedChild); + + table.putItem(r -> r.item(item)); + NestedBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Verify timestamps are set at root and nested level, empty list is preserved + assertThat(result.getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getTime(), is(MOCKED_INSTANT_NOW)); + assertThat(result.getLevel2().getChildList(), hasSize(0)); + + table.deleteTable(); + } + + private GetItemResponse getItemFromDDB(String tableName, String id) { + Map key = new HashMap<>(); + key.put("id", AttributeValue.builder().s(id).build()); + return getDynamoDbClient().getItem(GetItemRequest.builder() + .tableName(tableName) + .key(key) + .consistentRead(true) + .build()); + } + + /** + * Basic record class for testing simple timestamp operations with multiple timestamp fields, different converters, and + * flattened record structure. + */ + private static class BasicRecord { + private String id; + private String attribute; + private Instant createdDate; + private Instant lastUpdatedDate; + private Instant convertedLastUpdatedDate; + private Instant lastUpdatedDateInEpochMillis; + private FlattenedRecord flattenedRecord; + + public String getId() { + return id; + } + + public BasicRecord setId(String id) { + this.id = id; + return this; + } + + public String getAttribute() { + return attribute; + } + + public BasicRecord setAttribute(String attribute) { + this.attribute = attribute; + return this; + } + + public Instant getLastUpdatedDate() { + return lastUpdatedDate; + } + + public BasicRecord setLastUpdatedDate(Instant lastUpdatedDate) { + this.lastUpdatedDate = lastUpdatedDate; + return this; + } + + public Instant getCreatedDate() { + return createdDate; + } + + public BasicRecord setCreatedDate(Instant createdDate) { + this.createdDate = createdDate; + return this; + } + + public Instant getConvertedLastUpdatedDate() { + return convertedLastUpdatedDate; + } + + public BasicRecord setConvertedLastUpdatedDate(Instant convertedLastUpdatedDate) { + this.convertedLastUpdatedDate = convertedLastUpdatedDate; + return this; + } + + public Instant getLastUpdatedDateInEpochMillis() { + return lastUpdatedDateInEpochMillis; + } + + public BasicRecord setLastUpdatedDateInEpochMillis(Instant lastUpdatedDateInEpochMillis) { + this.lastUpdatedDateInEpochMillis = lastUpdatedDateInEpochMillis; + return this; + } + + public FlattenedRecord getFlattenedRecord() { + return flattenedRecord; + } + + public BasicRecord setFlattenedRecord(FlattenedRecord flattenedRecord) { + this.flattenedRecord = flattenedRecord; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BasicRecord that = (BasicRecord) o; + return Objects.equals(id, that.id) && + Objects.equals(attribute, that.attribute) && + Objects.equals(lastUpdatedDate, that.lastUpdatedDate) && + Objects.equals(createdDate, that.createdDate) && + Objects.equals(lastUpdatedDateInEpochMillis, that.lastUpdatedDateInEpochMillis) && + Objects.equals(convertedLastUpdatedDate, that.convertedLastUpdatedDate) && + Objects.equals(flattenedRecord, that.flattenedRecord); + } + + @Override + public int hashCode() { + return Objects.hash(id, attribute, lastUpdatedDate, createdDate, lastUpdatedDateInEpochMillis, + convertedLastUpdatedDate, flattenedRecord); + } + } + + /** + * Flattened record class for testing flattening functionality with auto-generated timestamps. + */ + private static class FlattenedRecord { + private Instant generated; + + public Instant getGenerated() { + return generated; + } + + public FlattenedRecord setGenerated(Instant generated) { + this.generated = generated; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FlattenedRecord that = (FlattenedRecord) o; + return Objects.equals(generated, that.generated); + } + + @Override + public int hashCode() { + return Objects.hash(generated); + } + } + + /** + * Nested record class for testing nested timestamp operations with multiple timestamp fields and nested structure using + * shared NestedBeanChild. + */ + private static class NestedRecord { + private String id; + private String attribute; + private Instant lastUpdatedDate; + private Instant createdDate; + private Instant lastUpdatedDateInEpochMillis; + private Instant convertedLastUpdatedDate; + private FlattenedRecord flattenedRecord; + private NestedBeanChild nestedRecord; + + public String getId() { + return id; + } + + public NestedRecord setId(String id) { + this.id = id; + return this; + } + + public String getAttribute() { + return attribute; + } + + public NestedRecord setAttribute(String attribute) { + this.attribute = attribute; + return this; + } + + public Instant getLastUpdatedDate() { + return lastUpdatedDate; + } + + public NestedRecord setLastUpdatedDate(Instant lastUpdatedDate) { + this.lastUpdatedDate = lastUpdatedDate; + return this; + } + + public Instant getCreatedDate() { + return createdDate; + } + + public NestedRecord setCreatedDate(Instant createdDate) { + this.createdDate = createdDate; + return this; + } + + public Instant getConvertedLastUpdatedDate() { + return convertedLastUpdatedDate; + } + + public NestedRecord setConvertedLastUpdatedDate(Instant convertedLastUpdatedDate) { + this.convertedLastUpdatedDate = convertedLastUpdatedDate; + return this; + } + + public Instant getLastUpdatedDateInEpochMillis() { + return lastUpdatedDateInEpochMillis; + } + + public NestedRecord setLastUpdatedDateInEpochMillis(Instant lastUpdatedDateInEpochMillis) { + this.lastUpdatedDateInEpochMillis = lastUpdatedDateInEpochMillis; + return this; + } + + public FlattenedRecord getFlattenedRecord() { + return flattenedRecord; + } + + public NestedRecord setFlattenedRecord(FlattenedRecord flattenedRecord) { + this.flattenedRecord = flattenedRecord; + return this; + } + + public NestedBeanChild getNestedRecord() { + return nestedRecord; + } + + public NestedRecord setNestedRecord(NestedBeanChild nestedRecord) { + this.nestedRecord = nestedRecord; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NestedRecord that = (NestedRecord) o; + return Objects.equals(id, that.id) && + Objects.equals(attribute, that.attribute) && + Objects.equals(lastUpdatedDate, that.lastUpdatedDate) && + Objects.equals(createdDate, that.createdDate) && + Objects.equals(lastUpdatedDateInEpochMillis, that.lastUpdatedDateInEpochMillis) && + Objects.equals(convertedLastUpdatedDate, that.convertedLastUpdatedDate) && + Objects.equals(flattenedRecord, that.flattenedRecord) && + Objects.equals(nestedRecord, that.nestedRecord); + } + + @Override + public int hashCode() { + return Objects.hash(id, attribute, lastUpdatedDate, createdDate, lastUpdatedDateInEpochMillis, + convertedLastUpdatedDate, flattenedRecord, nestedRecord); + } + } + + /** + * Recursive record class for testing recursive timestamp operations with multiple timestamp fields at different nesting + * levels. + */ + private static class RecursiveRecord { + private String id; + private Instant parentTimestamp; + private Instant childTimestamp; + private RecursiveRecord child; + + public String getId() { + return id; + } + + public RecursiveRecord setId(String id) { + this.id = id; + return this; + } + + public Instant getParentTimestamp() { + return parentTimestamp; + } + + public RecursiveRecord setParentTimestamp(Instant parentTimestamp) { + this.parentTimestamp = parentTimestamp; + return this; + } + + public Instant getChildTimestamp() { + return childTimestamp; + } + + public RecursiveRecord setChildTimestamp(Instant childTimestamp) { + this.childTimestamp = childTimestamp; + return this; + } + + public RecursiveRecord getChild() { + return child; + } + + public RecursiveRecord setChild(RecursiveRecord child) { + this.child = child; + return this; + } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof RecursiveRecord)) { + return false; + } + + RecursiveRecord that = (RecursiveRecord) o; + return Objects.equals(id, that.id) && Objects.equals(parentTimestamp, that.parentTimestamp) + && Objects.equals(childTimestamp, that.childTimestamp) && Objects.equals(child, that.child); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(id); + result = 31 * result + Objects.hashCode(parentTimestamp); + result = 31 * result + Objects.hashCode(childTimestamp); + result = 31 * result + Objects.hashCode(child); + return result; + } + } + + /** + * Record class for validation tests to ensure non-Instant types throw appropriate exceptions. + */ + private static class RecordWithStringUpdateDate { + private String id; + private String lastUpdatedDate; + + public String getId() { + return id; + } + + public RecordWithStringUpdateDate setId(String id) { + this.id = id; + return this; + } + + public String getLastUpdatedDate() { + return lastUpdatedDate; + } + + public RecordWithStringUpdateDate setLastUpdatedDate(String lastUpdatedDate) { + this.lastUpdatedDate = lastUpdatedDate; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RecordWithStringUpdateDate that = (RecordWithStringUpdateDate) o; + return Objects.equals(id, that.id) && Objects.equals(lastUpdatedDate, that.lastUpdatedDate); + } + + @Override + public int hashCode() { + return Objects.hash(id, lastUpdatedDate); + } + } + + /** + * Creates a table schema for FlattenedRecord with auto-generated timestamp attribute. + * + * @return TableSchema for FlattenedRecord + */ + private TableSchema createFlattenedRecordSchema() { + return StaticTableSchema.builder(FlattenedRecord.class) + .newItemSupplier(FlattenedRecord::new) + .addAttribute(Instant.class, a -> a.name("generated") + .getter(FlattenedRecord::getGenerated) + .setter(FlattenedRecord::setGenerated) + .tags(autoGeneratedTimestampAttribute())) + .build(); + } + + /** + * Creates a comprehensive table schema for BasicRecord with multiple timestamp fields, different converters, and flattened + * record structure. + * + * @return TableSchema for BasicRecord + */ + private TableSchema createBasicRecordSchema() { + TableSchema flattenedSchema = createFlattenedRecordSchema(); + + return StaticTableSchema.builder(BasicRecord.class) + .newItemSupplier(BasicRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(BasicRecord::getId) + .setter(BasicRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attribute") + .getter(BasicRecord::getAttribute) + .setter(BasicRecord::setAttribute)) + .addAttribute(Instant.class, a -> a.name("lastUpdatedDate") + .getter(BasicRecord::getLastUpdatedDate) + .setter(BasicRecord::setLastUpdatedDate) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(Instant.class, a -> a.name("createdDate") + .getter(BasicRecord::getCreatedDate) + .setter(BasicRecord::setCreatedDate) + .tags(autoGeneratedTimestampAttribute(), + updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(Instant.class, a -> a.name("lastUpdatedDateInEpochMillis") + .getter(BasicRecord::getLastUpdatedDateInEpochMillis) + .setter(BasicRecord::setLastUpdatedDateInEpochMillis) + .attributeConverter(EpochMillisFormatTestConverter.create()) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(Instant.class, a -> a.name("convertedLastUpdatedDate") + .getter(BasicRecord::getConvertedLastUpdatedDate) + .setter(BasicRecord::setConvertedLastUpdatedDate) + .attributeConverter(TimeFormatUpdateTestConverter.create()) + .tags(autoGeneratedTimestampAttribute())) + .flatten(flattenedSchema, BasicRecord::getFlattenedRecord, BasicRecord::setFlattenedRecord) + .build(); + } + + /** + * Creates a table schema for NestedRecord with multiple timestamp fields and nested structure. + * + * @return TableSchema for NestedRecord + */ + private TableSchema createNestedRecordSchema() { + TableSchema flattenedSchema = createFlattenedRecordSchema(); + + return StaticTableSchema.builder(NestedRecord.class) + .newItemSupplier(NestedRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedRecord::getId) + .setter(NestedRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attribute") + .getter(NestedRecord::getAttribute) + .setter(NestedRecord::setAttribute)) + .addAttribute(Instant.class, a -> a.name("lastUpdatedDate") + .getter(NestedRecord::getLastUpdatedDate) + .setter(NestedRecord::setLastUpdatedDate) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(Instant.class, a -> a.name("createdDate") + .getter(NestedRecord::getCreatedDate) + .setter(NestedRecord::setCreatedDate) + .tags(autoGeneratedTimestampAttribute(), + updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(Instant.class, a -> a.name("lastUpdatedDateInEpochMillis") + .getter(NestedRecord::getLastUpdatedDateInEpochMillis) + .setter(NestedRecord::setLastUpdatedDateInEpochMillis) + .attributeConverter(EpochMillisFormatTestConverter.create()) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(Instant.class, a -> a.name("convertedLastUpdatedDate") + .getter(NestedRecord::getConvertedLastUpdatedDate) + .setter(NestedRecord::setConvertedLastUpdatedDate) + .attributeConverter(TimeFormatUpdateTestConverter.create()) + .tags(autoGeneratedTimestampAttribute())) + .flatten(flattenedSchema, NestedRecord::getFlattenedRecord, NestedRecord::setFlattenedRecord) + .addAttribute(EnhancedType.documentOf(NestedBeanChild.class, + BeanTableSchema.create(NestedBeanChild.class), + b -> b.ignoreNulls(true)), + a -> a.name("nestedRecord") + .getter(NestedRecord::getNestedRecord) + .setter(NestedRecord::setNestedRecord)) + .build(); + } + + /** + * Creates a table schema for the deepest level (Level 3) of recursive records. + * + * @return TableSchema for RecursiveRecord Level 3 + */ + private TableSchema createRecursiveRecordLevel3Schema() { + return StaticTableSchema.builder(RecursiveRecord.class) + .newItemSupplier(RecursiveRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(RecursiveRecord::getId) + .setter(RecursiveRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("grandchildTimestamp") + .getter(RecursiveRecord::getChildTimestamp) + .setter(RecursiveRecord::setChildTimestamp) + .tags(autoGeneratedTimestampAttribute())) + .build(); + } + + /** + * Creates a table schema for the middle level (Level 2) of recursive records. + * + * @return TableSchema for RecursiveRecord Level 2 + */ + private TableSchema createRecursiveRecordLevel2Schema() { + TableSchema level3Schema = createRecursiveRecordLevel3Schema(); + + return StaticTableSchema.builder(RecursiveRecord.class) + .newItemSupplier(RecursiveRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(RecursiveRecord::getId) + .setter(RecursiveRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("childTimestamp") + .getter(RecursiveRecord::getChildTimestamp) + .setter(RecursiveRecord::setChildTimestamp) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(EnhancedType.documentOf(RecursiveRecord.class, level3Schema), + a -> a.name("child") + .getter(RecursiveRecord::getChild) + .setter(RecursiveRecord::setChild)) + .build(); + } + + /** + * Creates a table schema for the top level (Level 1) of recursive records. + * + * @return TableSchema for RecursiveRecord Level 1 + */ + private TableSchema createRecursiveRecordLevel1Schema() { + TableSchema level2Schema = createRecursiveRecordLevel2Schema(); + + return StaticTableSchema.builder(RecursiveRecord.class) + .newItemSupplier(RecursiveRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(RecursiveRecord::getId) + .setter(RecursiveRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("parentTimestamp") + .getter(RecursiveRecord::getParentTimestamp) + .setter(RecursiveRecord::setParentTimestamp) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(EnhancedType.documentOf(RecursiveRecord.class, level2Schema), + a -> a.name("child") + .getter(RecursiveRecord::getChild) + .setter(RecursiveRecord::setChild)) + .build(); + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampRecordTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampRecordTest.java deleted file mode 100644 index 5d5ccf4fdb4b..000000000000 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampRecordTest.java +++ /dev/null @@ -1,626 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, AutoTimestamp 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -package software.amazon.awssdk.enhanced.dynamodb.functionaltests; - -import static java.util.stream.Collectors.toList; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; -import static software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension.AttributeTags.autoGeneratedTimestampAttribute; -import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue; -import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; -import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.updateBehavior; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.UUID; -import java.util.stream.IntStream; -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.ExpectedException; -import org.mockito.Mockito; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Expression; -import software.amazon.awssdk.enhanced.dynamodb.OperationContext; -import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; -import software.amazon.awssdk.enhanced.dynamodb.converters.EpochMillisFormatTestConverter; -import software.amazon.awssdk.enhanced.dynamodb.converters.TimeFormatUpdateTestConverter; -import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension; -import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; -import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext; -import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; -import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; -import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; -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; - -public class AutoGeneratedTimestampRecordTest extends LocalDynamoDbSyncTestBase { - - public static final Instant MOCKED_INSTANT_NOW = Instant.now(Clock.fixed(Instant.parse("2019-01-13T14:00:00Z"), - ZoneOffset.UTC)); - - public static final Instant MOCKED_INSTANT_UPDATE_ONE = Instant.now(Clock.fixed(Instant.parse("2019-01-14T14:00:00Z"), - ZoneOffset.UTC)); - - - public static final Instant MOCKED_INSTANT_UPDATE_TWO = Instant.now(Clock.fixed(Instant.parse("2019-01-15T14:00:00Z"), - ZoneOffset.UTC)); - - private static final String TABLE_NAME = "table-name"; - private static final OperationContext PRIMARY_CONTEXT = - DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); - - private static final TableSchema FLATTENED_TABLE_SCHEMA = - StaticTableSchema.builder(FlattenedRecord.class) - .newItemSupplier(FlattenedRecord::new) - .addAttribute(Instant.class, a -> a.name("generated") - .getter(FlattenedRecord::getGenerated) - .setter(FlattenedRecord::setGenerated) - .tags(autoGeneratedTimestampAttribute())) - .build(); - - private static final TableSchema TABLE_SCHEMA = - StaticTableSchema.builder(Record.class) - .newItemSupplier(Record::new) - .addAttribute(String.class, a -> a.name("id") - .getter(Record::getId) - .setter(Record::setId) - .tags(primaryPartitionKey())) - .addAttribute(String.class, a -> a.name("attribute") - .getter(Record::getAttribute) - .setter(Record::setAttribute)) - .addAttribute(Instant.class, a -> a.name("lastUpdatedDate") - .getter(Record::getLastUpdatedDate) - .setter(Record::setLastUpdatedDate) - .tags(autoGeneratedTimestampAttribute())) - .addAttribute(Instant.class, a -> a.name("createdDate") - .getter(Record::getCreatedDate) - .setter(Record::setCreatedDate) - .tags(autoGeneratedTimestampAttribute(), - updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) - .addAttribute(Instant.class, a -> a.name("lastUpdatedDateInEpochMillis") - .getter(Record::getLastUpdatedDateInEpochMillis) - .setter(Record::setLastUpdatedDateInEpochMillis) - .attributeConverter(EpochMillisFormatTestConverter.create()) - .tags(autoGeneratedTimestampAttribute())) - .addAttribute(Instant.class, a -> a.name("convertedLastUpdatedDate") - .getter(Record::getConvertedLastUpdatedDate) - .setter(Record::setConvertedLastUpdatedDate) - .attributeConverter(TimeFormatUpdateTestConverter.create()) - .tags(autoGeneratedTimestampAttribute())) - .flatten(FLATTENED_TABLE_SCHEMA, Record::getFlattenedRecord, Record::setFlattenedRecord) - .build(); - - private final List> fakeItems = - IntStream.range(0, 4) - .mapToObj($ -> createUniqueFakeItem()) - .map(fakeItem -> TABLE_SCHEMA.itemToMap(fakeItem, true)) - .collect(toList()); - private final DynamoDbTable mappedTable; - - private final Clock mockCLock = Mockito.mock(Clock.class); - - - private final DynamoDbEnhancedClient enhancedClient = - DynamoDbEnhancedClient.builder() - .dynamoDbClient(getDynamoDbClient()) - .extensions(AutoGeneratedTimestampRecordExtension.builder().baseClock(mockCLock).build()) - .build(); - private final String concreteTableName; - - @Rule - public ExpectedException thrown = ExpectedException.none(); - - { - concreteTableName = getConcreteTableName("table-name"); - mappedTable = enhancedClient.table(concreteTableName, TABLE_SCHEMA); - } - - public static Record createUniqueFakeItem() { - Record record = new Record(); - record.setId(UUID.randomUUID().toString()); - return record; - } - - @Before - public void createTable() { - Mockito.when(mockCLock.instant()).thenReturn(MOCKED_INSTANT_NOW); - mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); - } - - @After - public void deleteTable() { - getDynamoDbClient().deleteTable(DeleteTableRequest.builder() - .tableName(getConcreteTableName("table-name")) - .build()); - } - - @Test - public void putNewRecordSetsInitialAutoGeneratedTimestamp() { - Record item = new Record().setId("id").setAttribute("one"); - mappedTable.putItem(r -> r.item(item)); - Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); - GetItemResponse itemAsStoredInDDB = getItemAsStoredFromDDB(); - FlattenedRecord flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_NOW); - Record expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_NOW) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_NOW) - .setCreatedDate(MOCKED_INSTANT_NOW) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_NOW) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("13 01 2019 14:00:00")); - } - - @Test - public void updateNewRecordSetsAutoFormattedDate() { - Record result = mappedTable.updateItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - GetItemResponse itemAsStoredInDDB = getItemAsStoredFromDDB(); - FlattenedRecord flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_NOW); - Record expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_NOW) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_NOW) - .setCreatedDate(MOCKED_INSTANT_NOW) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_NOW) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("13 01 2019 14:00:00")); - } - - @Test - public void putExistingRecordUpdatedWithAutoFormattedTimestamps() { - mappedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); - GetItemResponse itemAsStoredInDDB = getItemAsStoredFromDDB(); - FlattenedRecord flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_NOW); - Record expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_NOW) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_NOW) - .setCreatedDate(MOCKED_INSTANT_NOW) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_NOW) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("13 01 2019 14:00:00")); - - Mockito.when(mockCLock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); - mappedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); - itemAsStoredInDDB = getItemAsStoredFromDDB(); - flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_UPDATE_ONE); - expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_UPDATE_ONE) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_UPDATE_ONE) - // Note : Since we are doing PutItem second time, the createDate gets updated, - .setCreatedDate(MOCKED_INSTANT_UPDATE_ONE) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_UPDATE_ONE) - .setFlattenedRecord(flattenedRecord); - - System.out.println("result "+result); - assertThat(result, is(expectedRecord)); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("14 01 2019 14:00:00")); - } - - @Test - public void putItemFollowedByUpdates() { - mappedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); - GetItemResponse itemAsStoredInDDB = getItemAsStoredFromDDB(); - FlattenedRecord flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_NOW); - Record expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_NOW) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_NOW) - .setCreatedDate(MOCKED_INSTANT_NOW) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_NOW) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("13 01 2019 14:00:00")); - - //First Update - Mockito.when(mockCLock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); - - result = mappedTable.updateItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - itemAsStoredInDDB = getItemAsStoredFromDDB(); - flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_UPDATE_ONE); - expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_UPDATE_ONE) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_UPDATE_ONE) - .setCreatedDate(MOCKED_INSTANT_NOW) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_UPDATE_ONE) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("14 01 2019 14:00:00")); - - //Second Update - Mockito.when(mockCLock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_TWO); - result = mappedTable.updateItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - itemAsStoredInDDB = getItemAsStoredFromDDB(); - flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_UPDATE_TWO); - expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_UPDATE_TWO) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_UPDATE_TWO) - .setCreatedDate(MOCKED_INSTANT_NOW) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_UPDATE_TWO) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("15 01 2019 14:00:00")); - - System.out.println(Instant.ofEpochMilli(Long.parseLong(itemAsStoredInDDB.item().get("lastUpdatedDateInEpochMillis").n()))); - assertThat(Long.parseLong(itemAsStoredInDDB.item().get("lastUpdatedDateInEpochMillis").n()), - is(MOCKED_INSTANT_UPDATE_TWO.toEpochMilli())); - } - - @Test - public void putExistingRecordWithConditionExpressions() { - mappedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); - GetItemResponse itemAsStoredInDDB = getItemAsStoredFromDDB(); - FlattenedRecord flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_NOW); - Record expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_NOW) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_NOW) - .setCreatedDate(MOCKED_INSTANT_NOW) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_NOW) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("13 01 2019 14:00:00")); - - Expression conditionExpression = Expression.builder() - .expression("#k = :v OR #k = :v1") - .putExpressionName("#k", "attribute") - .putExpressionValue(":v", stringValue("one")) - .putExpressionValue(":v1", stringValue("wrong2")) - .build(); - - Mockito.when(mockCLock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); - mappedTable.putItem(PutItemEnhancedRequest.builder(Record.class) - .item(new Record().setId("id").setAttribute("one")) - .conditionExpression(conditionExpression) - .build()); - - result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); - flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_UPDATE_ONE); - expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_UPDATE_ONE) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_UPDATE_ONE) - // Note that this is a second putItem call so create date is updated. - .setCreatedDate(MOCKED_INSTANT_UPDATE_ONE) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_UPDATE_ONE) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - } - - @Test - public void updateExistingRecordWithConditionExpressions() { - mappedTable.updateItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - GetItemResponse itemAsStoredInDDB = getItemAsStoredFromDDB(); - // The data in DDB is stored in converted time format - assertThat(itemAsStoredInDDB.item().get("convertedLastUpdatedDate").s(), is("13 01 2019 14:00:00")); - Expression conditionExpression = Expression.builder() - .expression("#k = :v OR #k = :v1") - .putExpressionName("#k", "attribute") - .putExpressionValue(":v", stringValue("one")) - .putExpressionValue(":v1", stringValue("wrong2")) - .build(); - - Mockito.when(mockCLock.instant()).thenReturn(MOCKED_INSTANT_UPDATE_ONE); - mappedTable.updateItem(r -> r.item(new Record().setId("id").setAttribute("one")) - .conditionExpression(conditionExpression)); - - Record result = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id"))); - FlattenedRecord flattenedRecord = new FlattenedRecord().setGenerated(MOCKED_INSTANT_UPDATE_ONE); - Record expectedRecord = new Record().setId("id") - .setAttribute("one") - .setLastUpdatedDate(MOCKED_INSTANT_UPDATE_ONE) - .setConvertedLastUpdatedDate(MOCKED_INSTANT_UPDATE_ONE) - .setCreatedDate(MOCKED_INSTANT_NOW) - .setLastUpdatedDateInEpochMillis(MOCKED_INSTANT_UPDATE_ONE) - .setFlattenedRecord(flattenedRecord); - assertThat(result, is(expectedRecord)); - } - - @Test - public void putItemConditionTestFailure() { - - mappedTable.putItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - - Expression conditionExpression = Expression.builder() - .expression("#k = :v OR #k = :v1") - .putExpressionName("#k", "attribute") - .putExpressionValue(":v", stringValue("wrong1")) - .putExpressionValue(":v1", stringValue("wrong2")) - .build(); - - thrown.expect(ConditionalCheckFailedException.class); - mappedTable.putItem(PutItemEnhancedRequest.builder(Record.class) - .item(new Record().setId("id").setAttribute("one")) - .conditionExpression(conditionExpression) - .build()); - - } - - @Test - public void updateItemConditionTestFailure() { - mappedTable.updateItem(r -> r.item(new Record().setId("id").setAttribute("one"))); - Expression conditionExpression = Expression.builder() - .expression("#k = :v OR #k = :v1") - .putExpressionName("#k", "attribute") - .putExpressionValue(":v", stringValue("wrong1")) - .putExpressionValue(":v1", stringValue("wrong2")) - .build(); - thrown.expect(ConditionalCheckFailedException.class); - mappedTable.putItem(PutItemEnhancedRequest.builder(Record.class) - .item(new Record().setId("id").setAttribute("one")) - .conditionExpression(conditionExpression) - .build()); - } - - @Test - public void incorrectTypeForAutoUpdateTimestampThrowsException(){ - - thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("Attribute 'lastUpdatedDate' of Class type class java.lang.String is not a suitable " - + "Java Class type to be used as a Auto Generated Timestamp attribute. Only java.time." - + "Instant Class type is supported."); - StaticTableSchema.builder(RecordWithStringUpdateDate.class) - .newItemSupplier(RecordWithStringUpdateDate::new) - .addAttribute(String.class, a -> a.name("id") - .getter(RecordWithStringUpdateDate::getId) - .setter(RecordWithStringUpdateDate::setId) - .tags(primaryPartitionKey())) - .addAttribute(String.class, a -> a.name("lastUpdatedDate") - .getter(RecordWithStringUpdateDate::getLastUpdatedDate) - .setter(RecordWithStringUpdateDate::setLastUpdatedDate) - .tags(autoGeneratedTimestampAttribute())) - .build(); - } - - private GetItemResponse getItemAsStoredFromDDB() { - Map key = new HashMap<>(); - key.put("id", AttributeValue.builder().s("id").build()); - return getDynamoDbClient().getItem(GetItemRequest - .builder().tableName(concreteTableName) - .key(key) - .consistentRead(true).build()); - } - - private static class Record { - private String id; - private String attribute; - private Instant createdDate; - private Instant lastUpdatedDate; - private Instant convertedLastUpdatedDate; - private Instant lastUpdatedDateInEpochMillis; - private FlattenedRecord flattenedRecord; - - private String getId() { - return id; - } - - private Record setId(String id) { - this.id = id; - return this; - } - - private String getAttribute() { - return attribute; - } - - private Record setAttribute(String attribute) { - this.attribute = attribute; - return this; - } - - private Instant getLastUpdatedDate() { - return lastUpdatedDate; - } - - private Record setLastUpdatedDate(Instant lastUpdatedDate) { - this.lastUpdatedDate = lastUpdatedDate; - return this; - } - - private Instant getCreatedDate() { - return createdDate; - } - - private Record setCreatedDate(Instant createdDate) { - this.createdDate = createdDate; - return this; - } - - private Instant getConvertedLastUpdatedDate() { - return convertedLastUpdatedDate; - } - - private Record setConvertedLastUpdatedDate(Instant convertedLastUpdatedDate) { - this.convertedLastUpdatedDate = convertedLastUpdatedDate; - return this; - } - - private Instant getLastUpdatedDateInEpochMillis() { - return lastUpdatedDateInEpochMillis; - } - - private Record setLastUpdatedDateInEpochMillis(Instant lastUpdatedDateInEpochMillis) { - this.lastUpdatedDateInEpochMillis = lastUpdatedDateInEpochMillis; - return this; - } - - public FlattenedRecord getFlattenedRecord() { - return flattenedRecord; - } - - public Record setFlattenedRecord(FlattenedRecord flattenedRecord) { - this.flattenedRecord = flattenedRecord; - return this; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Record record = (Record) o; - return Objects.equals(id, record.id) && - Objects.equals(attribute, record.attribute) && - Objects.equals(lastUpdatedDate, record.lastUpdatedDate) && - Objects.equals(createdDate, record.createdDate) && - Objects.equals(lastUpdatedDateInEpochMillis, record.lastUpdatedDateInEpochMillis) && - Objects.equals(convertedLastUpdatedDate, record.convertedLastUpdatedDate) && - Objects.equals(flattenedRecord, record.flattenedRecord); - } - - @Override - public int hashCode() { - return Objects.hash(id, attribute, lastUpdatedDate, createdDate, lastUpdatedDateInEpochMillis, - convertedLastUpdatedDate, flattenedRecord); - } - - @Override - public String toString() { - return "Record{" + - "id='" + id + '\'' + - ", attribute='" + attribute + '\'' + - ", createdDate=" + createdDate + - ", lastUpdatedDate=" + lastUpdatedDate + - ", convertedLastUpdatedDate=" + convertedLastUpdatedDate + - ", lastUpdatedDateInEpochMillis=" + lastUpdatedDateInEpochMillis + - ", flattenedRecord=" + flattenedRecord + - '}'; - } - } - - private static class FlattenedRecord { - private Instant generated; - - public Instant getGenerated() { - return generated; - } - - public FlattenedRecord setGenerated(Instant generated) { - this.generated = generated; - return this; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - FlattenedRecord that = (FlattenedRecord) o; - return Objects.equals(generated, that.generated); - } - - @Override - public int hashCode() { - return Objects.hash(generated); - } - - @Override - public String toString() { - return "FlattenedRecord{" + - "generated=" + generated + - '}'; - } - } - - private static class RecordWithStringUpdateDate { - private String id; - private String lastUpdatedDate; - - - private String getId() { - return id; - } - - private RecordWithStringUpdateDate setId(String id) { - this.id = id; - return this; - } - - - private String getLastUpdatedDate() { - return lastUpdatedDate; - } - - private RecordWithStringUpdateDate setLastUpdatedDate(String lastUpdatedDate) { - this.lastUpdatedDate = lastUpdatedDate; - return this; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - RecordWithStringUpdateDate record = (RecordWithStringUpdateDate) o; - return Objects.equals(id, record.id) && - Objects.equals(lastUpdatedDate, record.lastUpdatedDate); - } - - @Override - public int hashCode() { - return Objects.hash(id, lastUpdatedDate); - } - - @Override - public String toString() { - return "RecordWithStringUpdateDate{" + - "id='" + id + '\'' + - ", lastUpdatedDate=" + lastUpdatedDate + - '}'; - } - } - - -} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java index 196d38282277..fe87b1ece6e0 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java @@ -2,9 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertTrue; import java.time.Instant; import java.util.Collections; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.After; @@ -34,19 +36,19 @@ public class UpdateBehaviorTest extends LocalDynamoDbSyncTestBase { private static final TableSchema TABLE_SCHEMA = TableSchema.fromClass(RecordWithUpdateBehaviors.class); - + private static final TableSchema TABLE_SCHEMA_FLATTEN_RECORD = TableSchema.fromClass(FlattenRecord.class); private final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(getDynamoDbClient()).extensions( + .dynamoDbClient(getDynamoDbClient()).extensions( Stream.concat(ExtensionResolver.defaultExtensions().stream(), Stream.of(AutoGeneratedTimestampRecordExtension.create())).collect(Collectors.toList())) - .build(); + .build(); private final DynamoDbTable mappedTable = - enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); - + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + private final DynamoDbTable flattenedMappedTable = enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA_FLATTEN_RECORD); @@ -62,7 +64,7 @@ public void deleteTable() { @Test public void updateBehaviors_firstUpdate() { - Instant currentTime = Instant.now(); + Instant currentTime = Instant.now().minusMillis(1); RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); record.setId("id123"); record.setCreatedOn(INSTANT_1); @@ -192,7 +194,7 @@ public void when_updatingNestedObjectWithSingleLevel_existingInformationIsPreser @Test public void when_updatingNestedObjectWithSingleLevel_default_mode_update_newMapCreated() { - + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); @@ -214,12 +216,12 @@ public void when_updatingNestedObjectWithSingleLevel_default_mode_update_newMapC RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null); + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, currentTime); } @Test public void when_updatingNestedObjectWithSingleLevel_with_no_mode_update_newMapCreated() { - + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); @@ -241,11 +243,12 @@ public void when_updatingNestedObjectWithSingleLevel_with_no_mode_update_newMapC RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null); + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, currentTime); } @Test public void when_updatingNestedObjectToEmptyWithSingleLevel_existingInformationIsPreserved_scalar_only_update() { + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); nestedRecord.setAttribute(TEST_ATTRIBUTE); @@ -266,7 +269,8 @@ public void when_updatingNestedObjectToEmptyWithSingleLevel_existingInformationI mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - assertThat(persistedRecord.getNestedRecord()).isNull(); + assertThat(persistedRecord.getNestedRecord()).isNotNull(); + assertThat(persistedRecord.getCreatedAutoUpdateOn()).isAfterOrEqualTo(currentTime); } private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long counter) { @@ -292,16 +296,16 @@ private void verifyMultipleLevelNestingTargetedUpdateBehavior(NestedRecordWithUp assertThat(nestedRecord.getNestedRecord().getNestedCounter()).isEqualTo(updatedInnerNestedCounter); assertThat(nestedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo( test_behav_attribute); - assertThat(nestedRecord.getNestedRecord().getNestedTimeAttribute()).isEqualTo(expected_time); + assertThat(nestedRecord.getNestedRecord().getNestedTimeAttribute()).isAfterOrEqualTo(expected_time); } private void verifySingleLevelNestingTargetedUpdateBehavior(NestedRecordWithUpdateBehavior nestedRecord, - long updatedNestedCounter, String expected_behav_attr, + long updatedNestedCounter, String expected_behav_attr, Instant expected_time) { assertThat(nestedRecord).isNotNull(); assertThat(nestedRecord.getNestedCounter()).isEqualTo(updatedNestedCounter); assertThat(nestedRecord.getNestedUpdateBehaviorAttribute()).isEqualTo(expected_behav_attr); - assertThat(nestedRecord.getNestedTimeAttribute()).isEqualTo(expected_time); + assertThat(nestedRecord.getNestedTimeAttribute()).isAfterOrEqualTo(expected_time); } @Test @@ -373,7 +377,7 @@ public void when_updatingNestedObjectWithMultipleLevels_inMapsOnlyMode_existingI @Test public void when_updatingNestedObjectWithMultipleLevels_default_mode_existingInformationIsErased() { - + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L); NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L); @@ -404,7 +408,7 @@ public void when_updatingNestedObjectWithMultipleLevels_default_mode_existingInf RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, innerNestedCounter, null, - null); + currentTime); } @Test @@ -470,15 +474,14 @@ public void when_emptyNestedRecordIsSet_emptyMapIsStoredInTable() { .build()); assertThat(getItemResponse.item().get("nestedRecord")).isNotNull(); - assertThat(getItemResponse.item().get("nestedRecord").toString()).isEqualTo("AttributeValue(M={nestedTimeAttribute" - + "=AttributeValue(NUL=true), " - + "nestedRecord=AttributeValue(NUL=true), " - + "attribute=AttributeValue(NUL=true), " - + "id=AttributeValue(NUL=true), " - + "nestedUpdateBehaviorAttribute=AttributeValue" - + "(NUL=true), nestedCounter=AttributeValue" - + "(NUL=true), nestedVersionedAttribute" - + "=AttributeValue(NUL=true)})"); + Map nestedRecord = getItemResponse.item().get("nestedRecord").m(); + assertThat(nestedRecord.get("nestedTimeAttribute")).isNotNull(); + assertTrue(nestedRecord.get("id").nul()); + assertTrue(nestedRecord.get("nestedRecord").nul()); + assertTrue(nestedRecord.get("attribute").nul()); + assertTrue(nestedRecord.get("nestedUpdateBehaviorAttribute").nul()); + assertTrue(nestedRecord.get("nestedCounter").nul()); + assertTrue(nestedRecord.get("nestedVersionedAttribute").nul()); } @@ -493,15 +496,15 @@ public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformatio FlattenRecord flattenRecord = new FlattenRecord(); flattenRecord.setCompositeRecord(compositeRecord); flattenRecord.setId("id456"); - + flattenedMappedTable.putItem(r -> r.item(flattenRecord)); - + NestedRecordWithUpdateBehavior updateNestedRecord = new NestedRecordWithUpdateBehavior(); updateNestedRecord.setNestedCounter(100L); - + CompositeRecord updateCompositeRecord = new CompositeRecord(); updateCompositeRecord.setNestedRecord(updateNestedRecord); - + FlattenRecord updatedFlattenRecord = new FlattenRecord(); updatedFlattenRecord.setId("id456"); updatedFlattenRecord.setCompositeRecord(updateCompositeRecord); @@ -515,7 +518,7 @@ public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformatio } - + @Test public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformationIsPreserved_scalar_only_update() { @@ -529,27 +532,27 @@ public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformat FlattenRecord flattenRecord = new FlattenRecord(); flattenRecord.setCompositeRecord(compositeRecord); flattenRecord.setId("id789"); - + flattenedMappedTable.putItem(r -> r.item(flattenRecord)); - + NestedRecordWithUpdateBehavior updateOuterNestedRecord = new NestedRecordWithUpdateBehavior(); updateOuterNestedRecord.setNestedCounter(100L); - + NestedRecordWithUpdateBehavior updateInnerNestedRecord = new NestedRecordWithUpdateBehavior(); updateInnerNestedRecord.setNestedCounter(50L); - + updateOuterNestedRecord.setNestedRecord(updateInnerNestedRecord); - + CompositeRecord updateCompositeRecord = new CompositeRecord(); updateCompositeRecord.setNestedRecord(updateOuterNestedRecord); - + FlattenRecord updateFlattenRecord = new FlattenRecord(); updateFlattenRecord.setCompositeRecord(updateCompositeRecord); updateFlattenRecord.setId("id789"); - + FlattenRecord persistedFlattenedRecord = flattenedMappedTable.updateItem(r -> r.item(updateFlattenRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - + assertThat(persistedFlattenedRecord.getCompositeRecord()).isNotNull(); verifyMultipleLevelNestingTargetedUpdateBehavior(persistedFlattenedRecord.getCompositeRecord().getNestedRecord(), 100L, 50L, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); @@ -562,6 +565,7 @@ public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformat */ @Test public void updateBehaviors_nested() { + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord = new NestedRecordWithUpdateBehavior(); nestedRecord.setId("id456"); @@ -579,6 +583,6 @@ public void updateBehaviors_nested() { assertThat(persistedRecord.getNestedRecord().getNestedVersionedAttribute()).isNull(); assertThat(persistedRecord.getNestedRecord().getNestedCounter()).isNull(); assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isNull(); - assertThat(persistedRecord.getNestedRecord().getNestedTimeAttribute()).isNull(); + assertThat(persistedRecord.getNestedRecord().getNestedTimeAttribute()).isAfterOrEqualTo(currentTime); } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/AutogeneratedTimestampTestModels.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/AutogeneratedTimestampTestModels.java new file mode 100644 index 000000000000..527fc44c6695 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/AutogeneratedTimestampTestModels.java @@ -0,0 +1,820 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests.models; + +import static software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension.AttributeTags.autoGeneratedTimestampAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; + +/** + * Test models specifically designed for auto-generated timestamp functionality testing. These models focus on the "time" + * attribute with @DynamoDbAutoGeneratedTimestampAttribute annotation and are used by AutoGeneratedTimestampExtensionTest. + */ +public final class AutogeneratedTimestampTestModels { + + private AutogeneratedTimestampTestModels() { + } + + @DynamoDbBean + public static class SimpleBeanWithList { + private String id; + private Instant time; + private List childList; + private List childStringList; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public SimpleBeanWithList setId(String id) { + this.id = id; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public SimpleBeanWithList setTime(Instant time) { + this.time = time; + return this; + } + + public List getChildList() { + return childList == null ? null : Collections.unmodifiableList(childList); + } + + public SimpleBeanWithList setChildList(List childList) { + this.childList = Collections.unmodifiableList(childList); + return this; + } + + public List getChildStringList() { + return childStringList; + } + + public SimpleBeanWithList setChildStringList(List childStringList) { + this.childStringList = childStringList; + return this; + } + } + + @DynamoDbBean + public static class SimpleBeanWithSet { + private String id; + private Instant time; + private Set childSet; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public SimpleBeanWithSet setId(String id) { + this.id = id; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public SimpleBeanWithSet setTime(Instant time) { + this.time = time; + return this; + } + + public Set getChildSet() { + return childSet == null ? null : Collections.unmodifiableSet(childSet); + } + + public SimpleBeanWithSet setChildSet(Set childSet) { + this.childSet = Collections.unmodifiableSet(childSet); + return this; + } + } + + @DynamoDbBean + public static class SimpleBeanWithMap { + private String id; + private Instant time; + private Map childMap; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public SimpleBeanWithMap setId(String id) { + this.id = id; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public SimpleBeanWithMap setTime(Instant time) { + this.time = time; + return this; + } + + public Map getChildMap() { + return childMap == null ? null : Collections.unmodifiableMap(childMap); + } + + public SimpleBeanWithMap setChildMap(Map childMap) { + this.childMap = Collections.unmodifiableMap(childMap); + return this; + } + } + + @DynamoDbBean + public static class SimpleBeanChild { + private String id; + private Instant time; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public SimpleBeanChild setId(String id) { + this.id = id; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public SimpleBeanChild setTime(Instant time) { + this.time = time; + return this; + } + } + + @DynamoDbBean + public static class NestedBeanWithList { + private String id; + private Instant time; + private NestedBeanChild level2; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public NestedBeanWithList setId(String id) { + this.id = id; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public NestedBeanWithList setTime(Instant time) { + this.time = time; + return this; + } + + public NestedBeanChild getLevel2() { + return level2; + } + + public NestedBeanWithList setLevel2(NestedBeanChild level2) { + this.level2 = level2; + return this; + } + } + + @DynamoDbBean + public static class NestedBeanChild { + private Instant time; + private List childList; + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public NestedBeanChild setTime(Instant time) { + this.time = time; + return this; + } + + public List getChildList() { + return childList == null ? null : Collections.unmodifiableList(childList); + } + + public NestedBeanChild setChildList(List childList) { + this.childList = Collections.unmodifiableList(childList); + return this; + } + } + + @DynamoDbImmutable(builder = SimpleImmutableRecordWithList.Builder.class) + public static final class SimpleImmutableRecordWithList { + private final String id; + private final Instant time; + private final List childList; + + private SimpleImmutableRecordWithList(Builder b) { + this.id = b.id; + this.time = b.time; + this.childList = b.childList; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public List getChildList() { + return childList == null ? null : Collections.unmodifiableList(childList); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private Instant time; + private List childList; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder time(Instant time) { + this.time = time; + return this; + } + + public Builder childList(List childList) { + this.childList = Collections.unmodifiableList(childList); + return this; + } + + public SimpleImmutableRecordWithList build() { + return new SimpleImmutableRecordWithList(this); + } + } + } + + @DynamoDbImmutable(builder = SimpleImmutableChild.Builder.class) + public static final class SimpleImmutableChild { + private final String id; + private final Instant time; + + private SimpleImmutableChild(Builder b) { + this.id = b.id; + this.time = b.time; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private Instant time; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder time(Instant time) { + this.time = time; + return this; + } + + public SimpleImmutableChild build() { + return new SimpleImmutableChild(this); + } + } + } + + @DynamoDbImmutable(builder = SimpleImmutableRecordWithSet.Builder.class) + public static final class SimpleImmutableRecordWithSet { + private final String id; + private final Instant time; + private final Set childSet; + + private SimpleImmutableRecordWithSet(Builder b) { + this.id = b.id; + this.time = b.time; + this.childSet = b.childSet; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public Set getChildSet() { + return childSet == null ? null : Collections.unmodifiableSet(childSet); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private Instant time; + private Set childSet; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder time(Instant time) { + this.time = time; + return this; + } + + public Builder childSet(Set childSet) { + this.childSet = Collections.unmodifiableSet(childSet); + return this; + } + + public SimpleImmutableRecordWithSet build() { + return new SimpleImmutableRecordWithSet(this); + } + } + } + + @DynamoDbImmutable(builder = SimpleImmutableRecordWithMap.Builder.class) + public static final class SimpleImmutableRecordWithMap { + private final String id; + private final Instant time; + private final Map childMap; + + private SimpleImmutableRecordWithMap(Builder b) { + this.id = b.id; + this.time = b.time; + this.childMap = b.childMap; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public Map getChildMap() { + return childMap == null ? null : Collections.unmodifiableMap(childMap); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private Instant time; + private Map childMap; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder time(Instant time) { + this.time = time; + return this; + } + + public Builder childMap(Map childMap) { + this.childMap = Collections.unmodifiableMap(childMap); + return this; + } + + public SimpleImmutableRecordWithMap build() { + return new SimpleImmutableRecordWithMap(this); + } + } + } + + @DynamoDbImmutable(builder = NestedImmutableRecordWithList.Builder.class) + public static final class NestedImmutableRecordWithList { + private final String id; + private final Instant time; + private final NestedImmutableChildRecordWithList level2; + + private NestedImmutableRecordWithList(Builder b) { + this.id = b.id; + this.time = b.time; + this.level2 = b.level2; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public NestedImmutableChildRecordWithList getLevel2() { + return level2; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private Instant time; + private NestedImmutableChildRecordWithList level2; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder time(Instant time) { + this.time = time; + return this; + } + + public Builder level2(NestedImmutableChildRecordWithList level2) { + this.level2 = level2; + return this; + } + + public NestedImmutableRecordWithList build() { + return new NestedImmutableRecordWithList(this); + } + } + } + + @DynamoDbImmutable(builder = NestedImmutableChildRecordWithList.Builder.class) + public static final class NestedImmutableChildRecordWithList { + private final Instant time; + + private NestedImmutableChildRecordWithList(Builder b) { + this.time = b.time; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private Instant time; + + public Builder time(Instant time) { + this.time = time; + return this; + } + + public NestedImmutableChildRecordWithList build() { + return new NestedImmutableChildRecordWithList(this); + } + } + } + + public static class SimpleStaticRecordWithList { + private String id; + private Instant time; + + public String getId() { + return id; + } + + public SimpleStaticRecordWithList setId(String id) { + this.id = id; + return this; + } + + public Instant getTime() { + return time; + } + + public SimpleStaticRecordWithList setTime(Instant time) { + this.time = time; + return this; + } + } + + public static class NestedStaticRecordWithList { + private String id; + private Instant time; + private NestedStaticChildRecordWithList level2; + + public String getId() { + return id; + } + + public NestedStaticRecordWithList setId(String id) { + this.id = id; + return this; + } + + public Instant getTime() { + return time; + } + + public NestedStaticRecordWithList setTime(Instant time) { + this.time = time; + return this; + } + + public NestedStaticChildRecordWithList getLevel2() { + return level2; + } + + public NestedStaticRecordWithList setLevel2(NestedStaticChildRecordWithList level2) { + this.level2 = level2; + return this; + } + } + + public static class NestedStaticChildRecordWithList { + private Instant time; + + public Instant getTime() { + return time; + } + + public NestedStaticChildRecordWithList setTime(Instant time) { + this.time = time; + return this; + } + } + + public static TableSchema buildStaticSchemaForSimpleRecordWithList() { + return StaticTableSchema.builder(SimpleStaticRecordWithList.class) + .newItemSupplier(SimpleStaticRecordWithList::new) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleStaticRecordWithList::getId) + .setter(SimpleStaticRecordWithList::setId) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("time") + .getter(SimpleStaticRecordWithList::getTime) + .setter(SimpleStaticRecordWithList::setTime) + .tags(autoGeneratedTimestampAttribute())) + .build(); + } + + public static TableSchema buildStaticSchemaForNestedRecordWithList() { + TableSchema level2Schema = + StaticTableSchema.builder(NestedStaticChildRecordWithList.class) + .newItemSupplier(NestedStaticChildRecordWithList::new) + .addAttribute(Instant.class, a -> a.name("time") + .getter(NestedStaticChildRecordWithList::getTime) + .setter(NestedStaticChildRecordWithList::setTime) + .tags(autoGeneratedTimestampAttribute())) + .build(); + + return StaticTableSchema.builder(NestedStaticRecordWithList.class) + .newItemSupplier(NestedStaticRecordWithList::new) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedStaticRecordWithList::getId) + .setter(NestedStaticRecordWithList::setId) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("time") + .getter(NestedStaticRecordWithList::getTime) + .setter(NestedStaticRecordWithList::setTime) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(EnhancedType.documentOf(NestedStaticChildRecordWithList.class, level2Schema), + a -> a.name("level2") + .getter(NestedStaticRecordWithList::getLevel2) + .setter(NestedStaticRecordWithList::setLevel2)) + .build(); + } + + public static TableSchema buildStaticImmutableSchemaForSimpleRecordWithList() { + TableSchema childSchema = + StaticImmutableTableSchema.builder(SimpleImmutableChild.class, + SimpleImmutableChild.Builder.class) + .newItemBuilder(SimpleImmutableChild::builder, + SimpleImmutableChild.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleImmutableChild::getId) + .setter(SimpleImmutableChild.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("time") + .getter(SimpleImmutableChild::getTime) + .setter(SimpleImmutableChild.Builder::time) + .tags(autoGeneratedTimestampAttribute())) + .build(); + + return StaticImmutableTableSchema.builder(SimpleImmutableRecordWithList.class, + SimpleImmutableRecordWithList.Builder.class) + .newItemBuilder(SimpleImmutableRecordWithList::builder, + SimpleImmutableRecordWithList.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleImmutableRecordWithList::getId) + .setter(SimpleImmutableRecordWithList.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("time") + .getter(SimpleImmutableRecordWithList::getTime) + .setter(SimpleImmutableRecordWithList.Builder::time) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(EnhancedType.listOf(EnhancedType.documentOf(SimpleImmutableChild.class, + childSchema)), + a -> a.name("childList") + .getter(SimpleImmutableRecordWithList::getChildList) + .setter(SimpleImmutableRecordWithList.Builder::childList)) + .build(); + } + + public static TableSchema buildStaticImmutableSchemaForNestedRecordWithList() { + TableSchema level2Schema = + StaticImmutableTableSchema.builder(NestedImmutableChildRecordWithList.class, + NestedImmutableChildRecordWithList.Builder.class) + .newItemBuilder(NestedImmutableChildRecordWithList::builder, + NestedImmutableChildRecordWithList.Builder::build) + .addAttribute(Instant.class, a -> a.name("time") + .getter(NestedImmutableChildRecordWithList::getTime) + .setter(NestedImmutableChildRecordWithList.Builder::time) + .tags(autoGeneratedTimestampAttribute())) + .build(); + + return StaticImmutableTableSchema.builder(NestedImmutableRecordWithList.class, + NestedImmutableRecordWithList.Builder.class) + .newItemBuilder(NestedImmutableRecordWithList::builder, + NestedImmutableRecordWithList.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedImmutableRecordWithList::getId) + .setter(NestedImmutableRecordWithList.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(Instant.class, a -> a.name("time") + .getter(NestedImmutableRecordWithList::getTime) + .setter(NestedImmutableRecordWithList.Builder::time) + .tags(autoGeneratedTimestampAttribute())) + .addAttribute(EnhancedType.documentOf(NestedImmutableChildRecordWithList.class, + level2Schema), + a -> a.name("level2") + .getter(NestedImmutableRecordWithList::getLevel2) + .setter(NestedImmutableRecordWithList.Builder::level2)) + .build(); + } + + /** + * Test model with an invalid root-level attribute name containing the reserved '_NESTED_ATTR_UPDATE_' pattern. Used to test + * validation of attribute names that conflict with internal DynamoDB Enhanced Client conventions. + */ + @DynamoDbBean + public static class BeanWithInvalidRootAttributeName { + private String id; + private Instant attr_NESTED_ATTR_UPDATE_; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithInvalidRootAttributeName setId(String id) { + this.id = id; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getAttr_NESTED_ATTR_UPDATE_() { + return attr_NESTED_ATTR_UPDATE_; + } + + public BeanWithInvalidRootAttributeName setAttr_NESTED_ATTR_UPDATE_(Instant attr_NESTED_ATTR_UPDATE_) { + this.attr_NESTED_ATTR_UPDATE_ = attr_NESTED_ATTR_UPDATE_; + return this; + } + } + + /** + * Test model with an invalid nested attribute name containing the reserved '_NESTED_ATTR_UPDATE_' pattern. Used to test + * validation of nested attribute names that conflict with internal DynamoDB Enhanced Client conventions. + */ + @DynamoDbBean + public static class BeanWithInvalidNestedAttributeName { + private String id; + private BeanWithInvalidNestedAttributeNameChild nestedChildAttribute; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithInvalidNestedAttributeName setId(String id) { + this.id = id; + return this; + } + + public BeanWithInvalidNestedAttributeNameChild getNestedChildAttribute() { + return nestedChildAttribute; + } + + public BeanWithInvalidNestedAttributeName setNestedChildAttribute(BeanWithInvalidNestedAttributeNameChild nestedChildAttribute) { + this.nestedChildAttribute = nestedChildAttribute; + return this; + } + + @DynamoDbBean + public static class BeanWithInvalidNestedAttributeNameChild { + private String id; + private BeanWithInvalidNestedAttributeNameChild nestedChildAttribute; + private Instant childAttr_NESTED_ATTR_UPDATE_; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithInvalidNestedAttributeNameChild setId(String id) { + this.id = id; + return this; + } + + public BeanWithInvalidNestedAttributeNameChild getNestedChildAttribute() { + return nestedChildAttribute; + } + + public BeanWithInvalidNestedAttributeNameChild setNestedChildAttribute(BeanWithInvalidNestedAttributeNameChild nestedChildAttribute) { + this.nestedChildAttribute = nestedChildAttribute; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getAttr_NESTED_ATTR_UPDATE_() { + return childAttr_NESTED_ATTR_UPDATE_; + } + + public BeanWithInvalidNestedAttributeNameChild setAttr_NESTED_ATTR_UPDATE_(Instant attr_NESTED_ATTR_UPDATE_) { + this.childAttr_NESTED_ATTR_UPDATE_ = attr_NESTED_ATTR_UPDATE_; + return this; + } + } + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java index 6e3bbdbdc9ad..30a6e142eedf 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java @@ -16,21 +16,82 @@ package software.amazon.awssdk.enhanced.dynamodb.internal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; -import org.junit.jupiter.api.Test; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +@RunWith(MockitoJUnitRunner.class) public class EnhancedClientUtilsTest { private static final AttributeValue PARTITION_VALUE = AttributeValue.builder().s("id123").build(); private static final AttributeValue SORT_VALUE = AttributeValue.builder().s("sort123").build(); + @Mock + private TableSchema mockSchema; + + @Mock + private AttributeConverter mockConverter; + + @Mock + private EnhancedType mockEnhancedType; + + @Mock + private EnhancedType mockParameterType; + + @Mock + private TableSchema mockNestedSchema; + + @Test + public void hasMap_forNotNullAttributeValueWithMap_returnsTrue() { + AttributeValue nullValue = AttributeValue.builder().nul(false).m(new HashMap<>()).build(); + + boolean result = EnhancedClientUtils.hasMap(nullValue); + + assertThat(result).isTrue(); + } + + @Test + public void hasMap_forNullAttributeValue_returnsFalse() { + AttributeValue nullValue = AttributeValue.builder().nul(true).build(); + + boolean result = EnhancedClientUtils.hasMap(nullValue); + + assertThat(result).isFalse(); + } + + @Test + public void hasMap_forNotNullAttributeValueWithoutMap_returnsFalse() { + AttributeValue nullValue = AttributeValue.builder().nul(false).build(); + + boolean result = EnhancedClientUtils.hasMap(nullValue); + + assertThat(result).isFalse(); + } + + @Test + public void hasMap_forAttributeValueNull_returnsFalse() { + + boolean result = EnhancedClientUtils.hasMap(null); + + assertThat(result).isFalse(); + } + @Test public void createKeyFromMap_partitionOnly() { Map itemMap = new HashMap<>(); @@ -64,4 +125,275 @@ public void cleanAttributeName_cleansSpecialCharacters() { assertThat(result).isEqualTo("a_b_c_d_e_f_g_h_i_j_k_l_m_n_o_p_q_r_s_t_u"); } + + @Test + public void getNestedSchema_withNullConverter_returnsEmpty() { + when(mockSchema.converterForAttribute("nonExistentAttribute")).thenReturn(null); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "nonExistentAttribute"); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withNullParentSchema_throwsIllegalArgumentException() { + assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(null, "attributeName")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Parent schema cannot be null"); + } + + @Test + public void getNestedSchema_withNullAttributeName_throwsIllegalArgumentException() { + assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(mockSchema, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Attribute name cannot be null or empty"); + } + + @Test + public void getNestedSchema_withEmptyAttributeName_throwsIllegalArgumentException() { + assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(mockSchema, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Attribute name cannot be null or empty"); + } + + @Test + public void getNestedSchema_withWhitespaceAttributeName_doesNotThrow() { + when(mockSchema.converterForAttribute(" ")).thenReturn(null); + + Optional> result = EnhancedClientUtils.getNestedSchema(mockSchema, " "); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withNullEnhancedType_returnsEmpty() { + when(mockSchema.converterForAttribute("attributeWithNullType")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(null); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "attributeWithNullType"); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withParameterizedType_extractsFirstParameter() { + List> parameters = Collections.singletonList(mockParameterType); + when(mockSchema.converterForAttribute("listAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(parameters); + when(mockParameterType.tableSchema()).thenReturn(Optional.of(mockNestedSchema)); + + Optional> result = EnhancedClientUtils.getNestedSchema(mockSchema, "listAttribute"); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(mockNestedSchema); + } + + @Test + public void getNestedSchema_withEmptyParameters_usesOriginalType() { + when(mockSchema.converterForAttribute("simpleAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(Collections.emptyList()); + when(mockEnhancedType.tableSchema()).thenReturn(Optional.of(mockNestedSchema)); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "simpleAttribute"); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(mockNestedSchema); + } + + @Test + public void getNestedSchema_withNullParameters_usesOriginalType() { + when(mockSchema.converterForAttribute("simpleAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(null); + when(mockEnhancedType.tableSchema()).thenReturn(Optional.of(mockNestedSchema)); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "simpleAttribute"); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(mockNestedSchema); + } + + @Test + public void getNestedSchema_withNoTableSchema_returnsEmpty() { + when(mockSchema.converterForAttribute("attributeWithoutSchema")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(Collections.emptyList()); + when(mockEnhancedType.tableSchema()).thenReturn(Optional.empty()); + + Optional> result = EnhancedClientUtils.getNestedSchema(mockSchema, "attributeWithoutSchema"); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withParameterizedTypeNoTableSchema_returnsEmpty() { + List> parameters = Collections.singletonList(mockParameterType); + when(mockSchema.converterForAttribute("listAttributeNoSchema")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(parameters); + when(mockParameterType.tableSchema()).thenReturn(Optional.empty()); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "listAttributeNoSchema"); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withValidInputs_returnsNestedSchema() { + when(mockSchema.converterForAttribute("validAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(Collections.emptyList()); + when(mockEnhancedType.tableSchema()).thenReturn(Optional.of(mockNestedSchema)); + + Optional> result = EnhancedClientUtils.getNestedSchema(mockSchema, "validAttribute"); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(mockNestedSchema); + } + + @Test + public void keyRef_withSimpleKey_returnsFormattedKey() { + String result = EnhancedClientUtils.keyRef("simpleKey"); + + assertThat(result).isEqualTo("#AMZN_MAPPED_simpleKey"); + } + + @Test + public void keyRef_withSpecialCharacters_cleansAndFormatsKey() { + String result = EnhancedClientUtils.keyRef("key*with.special-chars"); + + assertThat(result).isEqualTo("#AMZN_MAPPED_key_with_special_chars"); + } + + @Test + public void keyRef_withNestedKey_handlesNestedDelimiter() { + String nestedKey = "parent_NESTED_ATTR_UPDATE_child"; + String result = EnhancedClientUtils.keyRef(nestedKey); + + assertThat(result).contains("#AMZN_MAPPED_"); + assertThat(result).contains("parent"); + assertThat(result).contains("child"); + } + + @Test + public void valueRef_withSimpleValue_returnsFormattedValue() { + String result = EnhancedClientUtils.valueRef("simpleValue"); + + assertThat(result).isEqualTo(":AMZN_MAPPED_simpleValue"); + } + + @Test + public void valueRef_withSpecialCharacters_cleansAndFormatsValue() { + String result = EnhancedClientUtils.valueRef("value*with.special-chars"); + + assertThat(result).isEqualTo(":AMZN_MAPPED_value_with_special_chars"); + } + + @Test + public void valueRef_withNestedValue_handlesNestedDelimiter() { + String nestedValue = "parent_NESTED_ATTR_UPDATE_child"; + String result = EnhancedClientUtils.valueRef(nestedValue); + + assertThat(result).startsWith(":AMZN_MAPPED_"); + assertThat(result).contains("parent"); + assertThat(result).contains("child"); + } + + @Test + public void cleanAttributeName_withNoSpecialCharacters_returnsOriginal() { + String original = "normalAttributeName123"; + String result = EnhancedClientUtils.cleanAttributeName(original); + + assertThat(result).isSameAs(original); // Should return same instance when no changes needed + } + + @Test + public void isNullAttributeValue_withNullAttributeValue_returnsTrue() { + AttributeValue nullValue = AttributeValue.builder().nul(true).build(); + + boolean result = EnhancedClientUtils.isNullAttributeValue(nullValue); + + assertThat(result).isTrue(); + } + + @Test + public void isNullAttributeValue_withNonNullAttributeValue_returnsFalse() { + AttributeValue stringValue = AttributeValue.builder().s("test").build(); + + boolean result = EnhancedClientUtils.isNullAttributeValue(stringValue); + + assertThat(result).isFalse(); + } + + @Test + public void isNullAttributeValue_withFalseNullValue_returnsFalse() { + AttributeValue falseNullValue = AttributeValue.builder().nul(false).build(); + + boolean result = EnhancedClientUtils.isNullAttributeValue(falseNullValue); + + assertThat(result).isFalse(); + } + + @Test + public void createKeyFromItem_withPartitionKeyOnly_createsCorrectKey() { + FakeItem item = new FakeItem(); + item.setId("test-id"); + + Key result = EnhancedClientUtils.createKeyFromItem(item, FakeItem.getTableSchema(), + TableMetadata.primaryIndexName()); + + assertThat(result.partitionKeyValue()).isEqualTo(AttributeValue.builder().s("test-id").build()); + assertThat(result.sortKeyValue()).isEmpty(); + } + + @Test + public void createKeyFromItem_withPartitionAndSortKey_createsCorrectKey() { + FakeItemWithSort item = new FakeItemWithSort(); + item.setId("test-id"); + item.setSort("test-sort"); + + Key result = EnhancedClientUtils.createKeyFromItem(item, FakeItemWithSort.getTableSchema(), + TableMetadata.primaryIndexName()); + + assertThat(result.partitionKeyValue()).isEqualTo(AttributeValue.builder().s("test-id").build()); + assertThat(result.sortKeyValue()).isPresent(); + assertThat(result.sortKeyValue().get()).isEqualTo(AttributeValue.builder().s("test-sort").build()); + } + + @Test + public void readAndTransformSingleItem_withNullItemMap_returnsNull() { + Object result = EnhancedClientUtils.readAndTransformSingleItem(null, mockSchema, null, null); + + assertThat(result).isNull(); + } + + @Test + public void readAndTransformSingleItem_withEmptyItemMap_returnsNull() { + Map emptyMap = Collections.emptyMap(); + + Object result = EnhancedClientUtils.readAndTransformSingleItem(emptyMap, mockSchema, null, null); + + assertThat(result).isNull(); + } + + @Test + public void getItemsFromSupplier_withNullList_returnsNull() { + List result = EnhancedClientUtils.getItemsFromSupplier(null); + + assertThat(result).isNull(); + } + + @Test + public void getItemsFromSupplier_withEmptyList_returnsNull() { + List result = EnhancedClientUtils.getItemsFromSupplier(Collections.emptyList()); + + assertThat(result).isNull(); + } } \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java new file mode 100644 index 000000000000..34bbc9a615e8 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java @@ -0,0 +1,298 @@ +/* + * 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.utility; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +@RunWith(MockitoJUnitRunner.class) +public class NestedRecordUtilsTest { + + private static final String NESTED_OBJECT_UPDATE = "_NESTED_ATTR_UPDATE_"; + private static final Pattern PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); + private static final Pattern NESTED_ATTR_UPDATE_ = Pattern.compile("_NESTED_ATTR_UPDATE_"); + + @Mock + private TableSchema mockSchema; + + @Mock + private AttributeConverter mockConverter; + + @Mock + private EnhancedType mockType; + + @Test + public void getTableSchemaForListElement_withNullConverter_throwsIllegalArgumentException() { + when(mockSchema.converterForAttribute("nonExistentAttribute")).thenReturn(null); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(mockSchema, "nonExistentAttribute")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No converter found for attribute: nonExistentAttribute"); + } + + @Test + public void getTableSchemaForListElement_withEmptyRawClassParameters_throwsIllegalArgumentException() { + when(mockSchema.converterForAttribute("emptyParamsAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockType); + when(mockType.rawClassParameters()).thenReturn(Collections.emptyList()); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(mockSchema, "emptyParamsAttribute")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No type parameters found for list attribute: emptyParamsAttribute"); + } + + @Test + public void getTableSchemaForListElement_withNullRawClassParameters_throwsIllegalArgumentException() { + when(mockSchema.converterForAttribute("nullParamsAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockType); + when(mockType.rawClassParameters()).thenReturn(null); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(mockSchema, "nullParamsAttribute")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No type parameters found for list attribute: nullParamsAttribute"); + } + + @Test + public void getTableSchemaForListElement_withDeepNestedPath_returnsCorrectSchema() { + String nestedKey = "nestedItem" + NESTED_OBJECT_UPDATE + "tags"; + String[] parts = PATTERN.split(nestedKey); + + assertThat(parts).hasSize(2); + assertThat(parts[0]).isEqualTo("nestedItem"); + assertThat(parts[1]).isEqualTo("tags"); + + String deepNestedKey = String.join(NESTED_OBJECT_UPDATE, "parent", "child", "grandchild"); + String[] deepParts = PATTERN.split(deepNestedKey); + + assertThat(deepParts).hasSize(3); + assertThat(deepParts[0]).isEqualTo("parent"); + assertThat(deepParts[1]).isEqualTo("child"); + assertThat(deepParts[2]).isEqualTo("grandchild"); + } + + @Test + public void resolveSchemasPerPath_withEmptyAttributeMap_returnsOnlyRootSchema() { + Map emptyAttributes = new HashMap<>(); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(emptyAttributes, mockSchema); + + assertThat(result).hasSize(1); + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(mockSchema); + } + + @Test + public void resolveSchemasPerPath_withFlatAttributes_returnsOnlyRootSchema() { + Map flatAttributes = new HashMap<>(); + flatAttributes.put("id", AttributeValue.builder().s("test-id").build()); + flatAttributes.put("simpleAttribute", AttributeValue.builder().s("test-value").build()); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(flatAttributes, mockSchema); + + assertThat(result).hasSize(1); + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(mockSchema); + } + + @Test + public void resolveSchemasPerPath_withNestedAttributes_returnsCorrectSchemas() { + Map nestedAttributes = new HashMap<>(); + nestedAttributes.put("id", AttributeValue.builder().s("test-id").build()); + nestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "name", + AttributeValue.builder().s("nested-name").build()); + nestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "tags", + AttributeValue.builder().ss("tag1", "tag2").build()); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(nestedAttributes, mockSchema); + + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(mockSchema); + + boolean hasNestedPath = nestedAttributes.keySet().stream() + .anyMatch(key -> key.contains(NESTED_OBJECT_UPDATE)); + assertThat(hasNestedPath).isTrue(); + } + + @Test + public void resolveSchemasPerPath_withMultipleNestedPaths_returnsAllSchemas() { + Map multipleNestedAttributes = new HashMap<>(); + multipleNestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "name", + AttributeValue.builder().s("nested-name").build()); + multipleNestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "tags", + AttributeValue.builder().ss("tag1", "tag2").build()); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(multipleNestedAttributes, mockSchema); + + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(mockSchema); + + String[] parts1 = NESTED_ATTR_UPDATE_.split("nestedItem_NESTED_ATTR_UPDATE_name"); + String[] parts2 = NESTED_ATTR_UPDATE_.split("nestedItem_NESTED_ATTR_UPDATE_tags"); + + assertThat(parts1[0]).isEqualTo("nestedItem"); + assertThat(parts2[0]).isEqualTo("nestedItem"); + assertThat(parts1[0]).isEqualTo(parts2[0]); // Same nested path + } + + @Test + public void resolveSchemasPerPath_withDuplicateNestedPaths_doesNotDuplicateSchemas() { + Map duplicateNestedAttributes = new HashMap<>(); + duplicateNestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "name", + AttributeValue.builder().s("nested-name-1").build()); + duplicateNestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "tags", + AttributeValue.builder().ss("tag1").build()); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(duplicateNestedAttributes, mockSchema); + + // Assert - Should have root schema + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(mockSchema); + + // Verify both attributes share the same nested path + String path1 = "nestedItem" + NESTED_OBJECT_UPDATE + "name"; + String path2 = "nestedItem" + NESTED_OBJECT_UPDATE + "tags"; + + String[] parts1 = PATTERN.split(path1); + String[] parts2 = PATTERN.split(path2); + + assertThat(parts1[0]).isEqualTo(parts2[0]); // Same parent path + assertThat(parts1[0]).isEqualTo("nestedItem"); + } + + @Test + public void reconstructCompositeKey_withNullPath_returnsAttributeName() { + String result = NestedRecordUtils.reconstructCompositeKey(null, "attributeName"); + + assertThat(result).isEqualTo("attributeName"); + } + + @Test + public void reconstructCompositeKey_withEmptyPath_returnsAttributeName() { + String result = NestedRecordUtils.reconstructCompositeKey("", "attributeName"); + + assertThat(result).isEqualTo("attributeName"); + } + + @Test + public void reconstructCompositeKey_withNullAttributeName_throwsIllegalArgumentException() { + assertThatThrownBy(() -> NestedRecordUtils.reconstructCompositeKey("parent", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Attribute name cannot be null"); + } + + @Test + public void reconstructCompositeKey_withSimplePath_returnsCompositeKey() { + String result = NestedRecordUtils.reconstructCompositeKey("parent", "attributeName"); + + assertThat(result).isEqualTo("parent" + NESTED_OBJECT_UPDATE + "attributeName"); + } + + @Test + public void reconstructCompositeKey_withDottedPath_returnsCompositeKey() { + String result = NestedRecordUtils.reconstructCompositeKey("parent.child", "attributeName"); + + assertThat(result).isEqualTo(String.join(NESTED_OBJECT_UPDATE, + "parent", "child", "attributeName")); + } + + @Test + public void reconstructCompositeKey_withDeepDottedPath_returnsCompositeKey() { + String result = NestedRecordUtils.reconstructCompositeKey("parent.child.grandchild", "attributeName"); + + assertThat(result).isEqualTo(String.join(NESTED_OBJECT_UPDATE, + "parent", "child", "grandchild", "attributeName")); + } + + @Test + public void getTableSchemaForListElement_withNestedPathAndMissingSchema_throwsIllegalArgumentException() { + String nestedKey = String.join(NESTED_OBJECT_UPDATE, "parent", "child", "listAttribute"); + + // Mock the parent schema resolution to return empty + when(mockSchema.converterForAttribute("parent")).thenReturn(null); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(mockSchema, nestedKey)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unable to resolve schema for list element at: " + nestedKey); + } + + @Test + public void resolveSchemasPerPath_withDeepNestedPaths_buildsCorrectSchemaMap() { + Map deepNestedAttributes = new HashMap<>(); + deepNestedAttributes.put(String.join(NESTED_OBJECT_UPDATE, "level1", "level2", "level3", "attr"), + AttributeValue.builder().s("deep-value").build()); + + TableSchema level1Schema = mock(TableSchema.class); + TableSchema level2Schema = mock(TableSchema.class); + TableSchema level3Schema = mock(TableSchema.class); + + when(mockSchema.converterForAttribute("level1")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockType); + when(mockType.tableSchema()).thenReturn(Optional.of(level1Schema)); + + when(mockConverter.type()).thenReturn(mockType); + when(mockType.tableSchema()).thenReturn(Optional.of(level2Schema)); + + when(mockConverter.type()).thenReturn(mockType); + when(mockType.tableSchema()).thenReturn(Optional.of(level3Schema)); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(deepNestedAttributes, mockSchema); + + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(mockSchema); + assertThat(result.size()).isGreaterThan(1); + } + + @Test + public void reconstructCompositeKey_withMultipleDots_handlesCorrectly() { + String result = NestedRecordUtils.reconstructCompositeKey("a.b.c.d.e", "finalAttribute"); + + String expected = String.join(NESTED_OBJECT_UPDATE, "a", "b", "c", "d", "e", "finalAttribute"); + assertThat(result).isEqualTo(expected); + } + + @Test + public void reconstructCompositeKey_withWhitespaceInPath_preservesWhitespace() { + String result = NestedRecordUtils.reconstructCompositeKey("parent with spaces.child with spaces", "attr"); + + String expected = String.join(NESTED_OBJECT_UPDATE, "parent with spaces", "child with spaces", "attr"); + assertThat(result).isEqualTo(expected); + } + + @Test + public void reconstructCompositeKey_withSpecialCharactersInPath_preservesCharacters() { + String result = NestedRecordUtils.reconstructCompositeKey( + "parent-with-dashes.child_with_underscores", "attr"); + + String expected = String.join(NESTED_OBJECT_UPDATE, "parent-with-dashes", "child_with_underscores", "attr"); + assertThat(result).isEqualTo(expected); + } +} \ No newline at end of file