* 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 extends TableSchema>> 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 extends TableSchema>> 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 extends TableSchema>> 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 extends TableSchema>> 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 extends TableSchema>> nestedSchema = getNestedSchema(currentSchema, parts[i]);
+ if (nestedSchema.isPresent()) {
+ currentSchema = nestedSchema.get();
+ }
+ }
+ String attributeName = parts[parts.length - 1];
+ Optional extends TableSchema>> 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 extends TableSchema>> 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