diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-96aff9e.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-96aff9e.json
new file mode 100644
index 00000000000..eecef9799cf
--- /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 2ac27d91820..14628e4aa3b 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.hasMap;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.getListElementSchemaCached;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.getNestedSchemaCached;
+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,8 @@
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.internal.extensions.utility.NestedRecordUtils;
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,13 +75,20 @@
*
* 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.
*/
@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();
+ private static final AutoGeneratedTimestampAttribute AUTO_GENERATED_TIMESTAMP_ATTRIBUTE =
+ new AutoGeneratedTimestampAttribute();
+
private final Clock clock;
private AutoGeneratedTimestampRecordExtension() {
@@ -123,29 +141,361 @@ public static AutoGeneratedTimestampRecordExtension create() {
/**
* @param context The {@link DynamoDbExtensionContext.BeforeWrite} context containing the state of the execution.
* @return WriteModification Instance updated with attribute updated with Extension.
+ * @implNote Processing order: first {@link #processDirectNestedAttributes} for structured nested maps and lists of maps,
+ * then {@link #processFlattenedNestedAttributes} for flattened composite keys. This extension maintains temporary,
+ * per-operation caches for schema resolution and passes them to {@link NestedRecordUtils}. The caches are keyed by
+ * {@link NestedRecordUtils.SchemaLookupKey} (parent {@link TableSchema} identity and attribute name) and are used by
+ * {@link NestedRecordUtils#getNestedSchemaCached} and {@link NestedRecordUtils#getListElementSchemaCached}. The same nested
+ * schema cache is also passed into {@link NestedRecordUtils#resolveSchemasPerPath} to avoid recomputing intermediate nested
+ * schemas when both structured nested values and flattened nested keys are processed in the same write.
*/
@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);
+ Map>> nestedSchemaCache = new HashMap<>();
+ Map> listElementSchemaCache = new HashMap<>();
- if (customMetadataObject == null) {
+ processDirectNestedAttributes(itemToTransform,
+ updatedItems,
+ context.tableSchema(),
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache);
+
+ processFlattenedNestedAttributes(itemToTransform,
+ updatedItems,
+ context.tableSchema(),
+ currentInstant,
+ nestedSchemaCache);
+
+ if (updatedItems.isEmpty()) {
return WriteModification.builder().build();
}
- Map itemToTransform = new HashMap<>(context.items());
- customMetadataObject.forEach(
- key -> insertTimestampInItemToTransform(itemToTransform, key,
- context.tableSchema().converterForAttribute(key)));
+
+ // No overlap between nested map attributes and flattened composite keys: when IgnoreNullsMode.SCALAR_ONLY is used,
+ // UpdateItemOperation#transformItemToMapForUpdateExpression flattens maps into composite keys before extensions run.
+ itemToTransform.putAll(updatedItems);
+
return WriteModification.builder()
.transformedItem(Collections.unmodifiableMap(itemToTransform))
.build();
}
+ private void processDirectNestedAttributes(
+ Map itemToTransform,
+ Map updatedItems,
+ TableSchema> tableSchema,
+ Instant currentInstant,
+ Map>> nestedSchemaCache,
+ Map> listElementSchemaCache) {
+
+ itemToTransform.forEach((key, value) -> processTopLevelAttributeForTimestamps(key,
+ value,
+ tableSchema,
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache,
+ updatedItems));
+ }
+
+ private void processTopLevelAttributeForTimestamps(
+ String key,
+ AttributeValue value,
+ TableSchema> tableSchema,
+ Instant currentInstant,
+ Map>> nestedSchemaCache,
+ Map> listElementSchemaCache,
+ Map updatedItems) {
+
+ if (hasMap(value)) {
+ processTopLevelMapAttribute(key,
+ value,
+ tableSchema,
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache,
+ updatedItems);
+
+ } else if (value.hasL() && !value.l().isEmpty()) {
+ processTopLevelListOfMapsAttribute(key,
+ value,
+ tableSchema,
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache,
+ updatedItems);
+ }
+ }
+
+ private void processTopLevelMapAttribute(
+ String key,
+ AttributeValue value,
+ TableSchema> tableSchema,
+ Instant currentInstant,
+ Map>> nestedSchemaCache,
+ Map> listElementSchemaCache,
+ Map updatedItems) {
+
+ Optional> nestedSchemaOpt = getNestedSchemaCached(nestedSchemaCache, tableSchema, key);
+ if (!nestedSchemaOpt.isPresent()) {
+ return;
+ }
+
+ TableSchema> nestedSchema = nestedSchemaOpt.get();
+ Map processed =
+ processNestedObject(value.m(), nestedSchema, currentInstant, nestedSchemaCache, listElementSchemaCache);
+
+ if (processed != value.m()) {
+ updatedItems.put(key, AttributeValue.builder().m(processed).build());
+ }
+ }
+
+ /**
+ * When the attribute is a non-empty list whose first non-null element is a map, and a list element schema exists, rebuilds
+ * the list by applying {@link #processNestedObject} to each map element (other elements are copied unchanged). The new list
+ * is always written to {@code updatedItems} in that case, even if element contents are unchanged.
+ */
+ private void processTopLevelListOfMapsAttribute(
+ String key,
+ AttributeValue value,
+ TableSchema> tableSchema,
+ Instant currentInstant,
+ Map>> nestedSchemaCache,
+ Map> listElementSchemaCache,
+ Map updatedItems) {
+
+ AttributeValue firstElement = firstNonNullListElement(value);
+ if (!hasMap(firstElement)) {
+ return;
+ }
+
+ TableSchema> elementListSchema =
+ getListElementSchemaCached(listElementSchemaCache, tableSchema, key, nestedSchemaCache);
+ if (elementListSchema == null) {
+ return;
+ }
+
+ Collection updatedList = new ArrayList<>(value.l().size());
+ for (AttributeValue listItem : value.l()) {
+ if (hasMap(listItem)) {
+ updatedList.add(AttributeValue.builder()
+ .m(processNestedObject(listItem.m(),
+ elementListSchema,
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache))
+ .build());
+ } else {
+ updatedList.add(listItem);
+ }
+ }
+ updatedItems.put(key, AttributeValue.builder().l(updatedList).build());
+ }
+
+ private void processFlattenedNestedAttributes(
+ Map itemToTransform,
+ Map updatedItems,
+ TableSchema> tableSchema,
+ Instant currentInstant,
+ Map>> nestedSchemaCache) {
+
+ Map> schemasPerPath =
+ resolveSchemasPerPath(itemToTransform, tableSchema, nestedSchemaCache);
+
+ for (Map.Entry> pathEntry : schemasPerPath.entrySet()) {
+ String path = pathEntry.getKey();
+ TableSchema> schema = pathEntry.getValue();
+
+ Collection customMetadataObject = schema.tableMetadata()
+ .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class)
+ .orElse(null);
+
+ if (customMetadataObject == null) {
+ continue;
+ }
+
+ for (String attrName : customMetadataObject) {
+ AttributeConverter> converter = schema.converterForAttribute(attrName);
+ if (converter != null) {
+ insertTimestampInItemToTransform(updatedItems,
+ reconstructCompositeKey(path, attrName),
+ converter,
+ currentInstant);
+ }
+ }
+ }
+ }
+
+ private Map processNestedObject(
+ Map nestedMap,
+ TableSchema> nestedSchema,
+ Instant currentInstant,
+ Map>> nestedSchemaCache,
+ Map> listElementSchemaCache) {
+
+ Map updatedNestedMap =
+ applyAutoGeneratedTimestampsToMap(nestedMap, nestedSchema, currentInstant);
+ boolean updated = !Objects.equals(updatedNestedMap, nestedMap);
+
+ for (Map.Entry entry : nestedMap.entrySet()) {
+ String nestedKey = entry.getKey();
+ AttributeValue nestedValue = entry.getValue();
+
+ AttributeValue replacement = null;
+ if (nestedValue.hasM()) {
+ replacement = replacedNestedMapValueIfModified(nestedValue,
+ nestedKey,
+ nestedSchema,
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache);
+ } else if (nestedValue.hasL() && !nestedValue.l().isEmpty()) {
+ replacement = replacedNestedListValueIfModified(nestedValue,
+ nestedKey,
+ nestedSchema,
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache);
+ }
+
+ if (replacement != null) {
+ if (!updated) {
+ updatedNestedMap = new HashMap<>(nestedMap);
+ updated = true;
+ }
+ updatedNestedMap.put(nestedKey, replacement);
+ }
+ }
+
+ return updatedNestedMap;
+ }
+
+ private Map applyAutoGeneratedTimestampsToMap(
+ Map nestedMap,
+ TableSchema> nestedSchema,
+ Instant currentInstant) {
+
+ Collection customMetadataObject = nestedSchema.tableMetadata()
+ .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class)
+ .orElse(null);
+
+ if (customMetadataObject == null) {
+ return nestedMap;
+ }
+
+ Map updatedNestedMap = nestedMap;
+ boolean mapCopied = false;
+
+ for (String key : customMetadataObject) {
+ AttributeConverter> converter = nestedSchema.converterForAttribute(key);
+ if (converter != null) {
+ if (!mapCopied) {
+ updatedNestedMap = new HashMap<>(nestedMap);
+ mapCopied = true;
+ }
+ insertTimestampInItemToTransform(updatedNestedMap, key, converter, currentInstant);
+ }
+ }
+
+ return updatedNestedMap;
+ }
+
+ /**
+ * @return a map {@link AttributeValue} if the nested map changed after recursion; {@code null} if unchanged or if there is
+ * no nested schema for this key
+ */
+ private AttributeValue replacedNestedMapValueIfModified(
+ AttributeValue nestedValue,
+ String nestedKey,
+ TableSchema> nestedSchema,
+ Instant currentInstant,
+ Map>> nestedSchemaCache,
+ Map> listElementSchemaCache) {
+
+ Optional> childSchemaOpt = getNestedSchemaCached(nestedSchemaCache, nestedSchema, nestedKey);
+ if (!childSchemaOpt.isPresent()) {
+ return null;
+ }
+
+ Map processed =
+ processNestedObject(nestedValue.m(),
+ childSchemaOpt.get(),
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache);
+
+ if (Objects.equals(processed, nestedValue.m())) {
+ return null;
+ }
+
+ return AttributeValue.builder().m(processed).build();
+ }
+
+ /**
+ * @return a list {@link AttributeValue} if at least one map element changed; {@code null} if unchanged or if this is not
+ * a list of maps with a resolvable element schema
+ */
+ private AttributeValue replacedNestedListValueIfModified(
+ AttributeValue nestedValue,
+ String nestedKey,
+ TableSchema> nestedSchema,
+ Instant currentInstant,
+ Map>> nestedSchemaCache,
+ Map> listElementSchemaCache) {
+
+ AttributeValue firstElement = firstNonNullListElement(nestedValue);
+ if (!hasMap(firstElement)) {
+ return null;
+ }
+
+ TableSchema> listElementSchema =
+ getListElementSchemaCached(listElementSchemaCache, nestedSchema, nestedKey, nestedSchemaCache);
+ if (listElementSchema == null) {
+ return null;
+ }
+
+ Collection updatedList = new ArrayList<>(nestedValue.l().size());
+ boolean listModified = false;
+
+ for (AttributeValue listItem : nestedValue.l()) {
+ if (hasMap(listItem)) {
+ Map processedItem =
+ processNestedObject(listItem.m(),
+ listElementSchema,
+ currentInstant,
+ nestedSchemaCache,
+ listElementSchemaCache);
+
+ AttributeValue updatedItem = AttributeValue.builder().m(processedItem).build();
+ updatedList.add(updatedItem);
+
+ if (!Objects.equals(updatedItem, listItem)) {
+ listModified = true;
+ }
+ } else {
+ updatedList.add(listItem);
+ }
+ }
+
+ if (!listModified) {
+ return null;
+ }
+
+ return AttributeValue.builder().l(updatedList).build();
+ }
+
+ private static AttributeValue firstNonNullListElement(AttributeValue listAttribute) {
+ return listAttribute.l().stream().filter(Objects::nonNull).findFirst().orElse(null);
+ }
+
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));
}
/**
@@ -153,7 +503,6 @@ private void insertTimestampInItemToTransform(Map itemTo
*/
@NotThreadSafe
public static final class Builder {
-
private Clock baseClock;
private Builder() {
@@ -182,14 +531,14 @@ public AutoGeneratedTimestampRecordExtension build() {
private static class AutoGeneratedTimestampAttribute implements StaticAttributeTag {
-
@Override
- public void validateType(String attributeName, EnhancedType type,
+ public void validateType(String attributeName,
+ EnhancedType type,
AttributeValueType attributeValueType) {
-
Validate.notNull(type, "type is null");
Validate.notNull(type.rawClass(), "rawClass is null");
Validate.notNull(attributeValueType, "attributeValueType is null");
+ validateAttributeName(attributeName);
if (!type.rawClass().equals(Instant.class)) {
throw new IllegalArgumentException(String.format(
@@ -204,5 +553,13 @@ 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 065edfd2a29..3c8e4808500 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 61d750e98a7..e102b9b91d2 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 {
@@ -146,7 +150,7 @@ public static Page readAndTransformPaginatedItems(
.scannedCount(scannedCount.apply(response))
.consumedCapacity(consumedCapacity.apply(response));
- if (getLastEvaluatedKey.apply(response) != null && !getLastEvaluatedKey.apply(response).isEmpty()) {
+ if (CollectionUtils.isNotEmpty(getLastEvaluatedKey.apply(response))) {
pageBuilder.lastEvaluatedKey(getLastEvaluatedKey.apply(response));
}
return pageBuilder.build();
@@ -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, String 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().flatMap(Optional::of);
+ }
}
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 00000000000..90bd1f46e5b
--- /dev/null
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java
@@ -0,0 +1,262 @@
+/*
+ * 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.Objects;
+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) {
+ return getTableSchemaForListElement(rootSchema, key, new HashMap<>());
+ }
+
+ /**
+ * Same as {@link #getTableSchemaForListElement(TableSchema, String)} but allows callers to provide a shared per-operation
+ * cache for nested schema lookups.
+ */
+ public static TableSchema> getTableSchemaForListElement(
+ TableSchema> rootSchema,
+ String key,
+ Map>> nestedSchemaCache) {
+
+ if (key.contains(NESTED_OBJECT_UPDATE)) {
+ return listElementSchemaForDelimitedKey(rootSchema, key, nestedSchemaCache);
+ }
+
+ Optional> staticSchema = getNestedSchemaCached(nestedSchemaCache, rootSchema, key);
+ if (staticSchema.isPresent()) {
+ return staticSchema.get();
+ }
+
+ 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);
+ }
+ return TableSchema.fromClass(rawClassParameters.get(0).rawClass());
+ }
+
+ private static TableSchema> listElementSchemaForDelimitedKey(
+ TableSchema> rootSchema,
+ String key,
+ Map>> nestedSchemaCache) {
+
+ String[] parts = NESTED_OBJECT_PATTERN.split(key);
+ TableSchema> currentSchema = rootSchema;
+
+ for (int i = 0; i < parts.length - 1; i++) {
+ Optional> nestedSchema =
+ getNestedSchemaCached(nestedSchemaCache, currentSchema, parts[i]);
+ if (nestedSchema.isPresent()) {
+ currentSchema = nestedSchema.get();
+ }
+ }
+
+ String attributeName = parts[parts.length - 1];
+ return getNestedSchemaCached(nestedSchemaCache, currentSchema, attributeName)
+ .orElseThrow(() -> new IllegalArgumentException("Unable to resolve schema for list element at: " + key));
+ }
+
+ /**
+ * 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) {
+ return resolveSchemasPerPath(attributesToSet, rootSchema, new HashMap<>());
+ }
+
+ /**
+ * Same as {@link #resolveSchemasPerPath(Map, TableSchema)} but allows callers to provide a shared per-operation cache for
+ * nested schema lookups.
+ */
+ public static Map> resolveSchemasPerPath(
+ Map attributesToSet,
+ TableSchema> rootSchema,
+ Map>> nestedSchemaCache) {
+
+ 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 =
+ getNestedSchemaCached(nestedSchemaCache, currentSchema, parts[i]);
+
+ if (nestedSchema.isPresent()) {
+ TableSchema> resolved = nestedSchema.get();
+ schemaMap.put(path, resolved);
+ currentSchema = resolved;
+ }
+ } 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;
+ }
+
+ /**
+ * Cached wrapper around {@link software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils#getNestedSchema}. Cache
+ * key is based on (parent schema identity, attribute name).
+ */
+ public static Optional> getNestedSchemaCached(
+ Map>> cache,
+ TableSchema> parentSchema,
+ String attributeName) {
+
+ SchemaLookupKey key = new SchemaLookupKey(parentSchema, attributeName);
+ return cache.computeIfAbsent(key, k -> getNestedSchema(parentSchema, attributeName));
+ }
+
+ /**
+ * Cached wrapper for resolving list element schema, storing results (including null) in the provided cache.
+ *
+ * Note: {@link #getTableSchemaForListElement(TableSchema, String, Map)} does not return null today, but this helper is used
+ * by callers that previously cached the list element schema separately, and it keeps the "cache null" behavior.
+ */
+ public static TableSchema> getListElementSchemaCached(
+ Map> cache,
+ TableSchema> parentSchema,
+ String attributeName,
+ Map>> nestedSchemaCache) {
+
+ SchemaLookupKey key = new SchemaLookupKey(parentSchema, attributeName);
+
+ if (cache.containsKey(key)) {
+ return cache.get(key);
+ }
+
+ TableSchema> schema = getTableSchemaForListElement(parentSchema, attributeName, nestedSchemaCache);
+ cache.put(key, schema);
+ return schema;
+ }
+
+ /**
+ * Identity-based cache key for schema lookups: - compares TableSchema by identity (==) to avoid depending on its
+ * equals/hashCode semantics - compares attribute name by value
+ */
+ public static final class SchemaLookupKey {
+ private final TableSchema> parentSchema;
+ private final String attributeName;
+
+ public SchemaLookupKey(TableSchema> parentSchema, String attributeName) {
+ this.parentSchema = parentSchema;
+ this.attributeName = attributeName;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ SchemaLookupKey that = (SchemaLookupKey) o;
+ return Objects.equals(parentSchema, that.parentSchema) && Objects.equals(attributeName, that.attributeName);
+ }
+
+ @Override
+ public int hashCode() {
+ return 31 * System.identityHashCode(parentSchema) + (attributeName == null ? 0 : attributeName.hashCode());
+ }
+ }
+}
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 00000000000..91883423539
--- /dev/null
+++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java
@@ -0,0 +1,2350 @@
+/*
+ * 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 org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+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.Collection;
+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 java.util.Optional;
+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.AttributeConverter;
+import software.amazon.awssdk.enhanced.dynamodb.DefaultAttributeConverterProvider;
+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.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.extensions.WriteModification;
+import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext;
+import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext;
+import software.amazon.awssdk.enhanced.dynamodb.internal.operations.OperationName;
+import software.amazon.awssdk.enhanced.dynamodb.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 static final String AUTO_TS_EXTENSION_METADATA_KEY =
+ "AutoGeneratedTimestampExtension:AutoGeneratedTimestampAttribute";
+
+ private static final String EXTENSION_DIRECT_TEST_TABLE = "extension-direct-test-table";
+
+ private static final OperationContext EXTENSION_DIRECT_TEST_OPERATION_CONTEXT =
+ DefaultOperationContext.create(EXTENSION_DIRECT_TEST_TABLE, TableMetadata.primaryIndexName());
+
+ /** Plain string element appended after document-shaped map entries in list-attribute tests. */
+ private static final AttributeValue STRING_ELEMENT_AFTER_DOCUMENT_MAPS = AttributeValue.fromS("literal-after-maps");
+
+ /** Plain string element placed before document-shaped map entries in list-attribute tests. */
+ private static final AttributeValue STRING_ELEMENT_BEFORE_DOCUMENT_MAPS = AttributeValue.fromS("literal-before-maps");
+
+ private final Clock mockClock = Mockito.mock(Clock.class);
+
+ /** Same clock as {@link #enhancedClient}; used with {@link DefaultDynamoDbExtensionContext} for direct extension tests. */
+ private final AutoGeneratedTimestampRecordExtension standaloneExtension =
+ AutoGeneratedTimestampRecordExtension.builder().baseClock(mockClock).build();
+
+ 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();
+ }
+
+ @Test
+ public void beforeWrite_withStaticSchema_plainNestedWithoutTimestamps_preservesNestedAttributes() {
+ String tableName = getConcreteTableName("static-plain-nested-table");
+ DynamoDbTable table =
+ enhancedClient.table(tableName, schemaRootWithPlainNested());
+ table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
+
+ String expectedDetail = "plain-nested-field-value";
+ PlainNested plainNested = new PlainNested();
+ plainNested.setDetail(expectedDetail);
+ RootWithPlainNested item = new RootWithPlainNested();
+ item.setId("1");
+ item.setPlainNested(plainNested);
+
+ table.putItem(item);
+ RootWithPlainNested result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ assertThat(result.getPlainNested().getDetail(), is(expectedDetail));
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void beforeWrite_withStaticSchema_compositeNested_setsTimestampOnTimestampedChildWhenEmptyNestedPresent() {
+ String tableName = getConcreteTableName("static-composite-nested-table");
+ DynamoDbTable table =
+ enhancedClient.table(tableName, schemaRootWithCompositeNested());
+ table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
+
+ CompositeNested compositeNested = new CompositeNested();
+ compositeNested.setTimestampedChild(new SimpleBeanChild());
+ compositeNested.setEmptyNested(new EmptyNested());
+
+ RootWithCompositeNested item = new RootWithCompositeNested();
+ item.setId("1");
+ item.setNested(compositeNested);
+
+ table.putItem(item);
+ RootWithCompositeNested result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ assertThat(result.getNested().getTimestampedChild().getTime(), is(MOCKED_INSTANT_NOW));
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void beforeWrite_withStaticSchema_nestedChildList_setsTimestampsOnAllElements() {
+ String tableName = getConcreteTableName("static-nested-child-list-table");
+ DynamoDbTable table =
+ enhancedClient.table(tableName, schemaNestedBeanWithListChildListOnly());
+ table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
+
+ NestedBeanChild level2 = new NestedBeanChild();
+ level2.setChildList(Arrays.asList(new SimpleBeanChild(), new SimpleBeanChild()));
+
+ NestedBeanWithList item = new NestedBeanWithList().setId("1").setLevel2(level2);
+
+ table.putItem(item);
+ NestedBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ assertThat(result.getLevel2().getChildList(), hasSize(2));
+ assertThat(result.getLevel2().getChildList().get(0).getTime(), is(MOCKED_INSTANT_NOW));
+ assertThat(result.getLevel2().getChildList().get(1).getTime(), is(MOCKED_INSTANT_NOW));
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void beforeWrite_withStaticSchema_plainNestedList_preservesEachListElementAttributes() {
+ String tableName = getConcreteTableName("static-plain-nested-list-table");
+ DynamoDbTable table =
+ enhancedClient.table(tableName, schemaRootWithPlainNestedList());
+ table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
+
+ String expectedDetailForEachElement = "same-plain-nested-detail-for-each-element";
+ PlainNested firstElement = new PlainNested();
+ firstElement.setDetail(expectedDetailForEachElement);
+ PlainNested secondElement = new PlainNested();
+ secondElement.setDetail(expectedDetailForEachElement);
+ NestedPlainList nestedPlainList = new NestedPlainList();
+ nestedPlainList.setItems(Arrays.asList(firstElement, secondElement));
+
+ RootWithPlainNestedList item = new RootWithPlainNestedList();
+ item.setId("1");
+ item.setNested(nestedPlainList);
+
+ table.putItem(item);
+ RootWithPlainNestedList result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ assertThat(result.getNested().getItems(), hasSize(2));
+ assertThat(result.getNested().getItems().get(0).getDetail(), is(expectedDetailForEachElement));
+ assertThat(result.getNested().getItems().get(1).getDetail(), is(expectedDetailForEachElement));
+
+ table.deleteTable();
+ }
+
+ /**
+ * These tests call {@link AutoGeneratedTimestampRecordExtension#beforeWrite} through {@link DefaultDynamoDbExtensionContext}
+ * only (no DynamoDB). Test method names use the form {@code beforeWrite_scenario_thenOutcome}.
+ */
+
+ @Test
+ public void beforeWrite_itemContainsOnlyStringList_thenStringListUnchanged() {
+ TableSchema schema = BeanTableSchema.create(SimpleBeanWithList.class);
+ SimpleBeanWithList record = new SimpleBeanWithList()
+ .setId("1")
+ .setChildStringList(Arrays.asList("a", "b"));
+ record.setChildList(new ArrayList<>());
+
+ Map putItem = new HashMap<>(schema.itemToMap(record, false));
+ WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
+ Map transformed = modification.transformedItem();
+
+ assertThat(transformed, is(notNullValue()));
+ assertThat(transformed.get("childStringList").l(), hasSize(2));
+ }
+
+ @Test
+ public void beforeWrite_topLevelChildListHasMapsThenString_thenMapsTimestampedAndStringUnchanged() {
+ String expectedWrittenInstant = MOCKED_INSTANT_NOW.toString();
+ TableSchema schema = BeanTableSchema.create(SimpleBeanWithList.class);
+ SimpleBeanWithList record = new SimpleBeanWithList()
+ .setId("1")
+ .setChildList(Arrays.asList(new SimpleBeanChild().setId("c1"), new SimpleBeanChild().setId("c2")));
+
+ Map putItem = new HashMap<>(schema.itemToMap(record, false));
+ List childListElements = new ArrayList<>(putItem.get("childList").l());
+ childListElements.add(STRING_ELEMENT_AFTER_DOCUMENT_MAPS);
+ putItem.put("childList", AttributeValue.builder().l(childListElements).build());
+
+ WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
+ Map transformed = modification.transformedItem();
+ List writtenChildList = transformed.get("childList").l();
+
+ assertThat(transformed, is(notNullValue()));
+ assertThat(writtenChildList, hasSize(3));
+ assertThat(writtenChildList.get(0).m().get("time").s(), is(expectedWrittenInstant));
+ assertThat(writtenChildList.get(1).m().get("time").s(), is(expectedWrittenInstant));
+ assertThat(writtenChildList.get(2), is(STRING_ELEMENT_AFTER_DOCUMENT_MAPS));
+ }
+
+ @Test
+ public void beforeWrite_nestedChildListHasMapsThenString_thenMapsTimestampedAndStringUnchanged() {
+ String expectedWrittenInstant = MOCKED_INSTANT_NOW.toString();
+ TableSchema schema = BeanTableSchema.create(NestedBeanWithList.class);
+ NestedBeanChild level2 = new NestedBeanChild();
+ level2.setChildList(Arrays.asList(new SimpleBeanChild().setId("a"), new SimpleBeanChild().setId("b")));
+ NestedBeanWithList record = new NestedBeanWithList().setId("1").setLevel2(level2);
+
+ Map putItem = new HashMap<>(schema.itemToMap(record, false));
+ Map level2Map = new HashMap<>(putItem.get("level2").m());
+ List nestedChildList = new ArrayList<>(level2Map.get("childList").l());
+ nestedChildList.add(STRING_ELEMENT_AFTER_DOCUMENT_MAPS);
+ level2Map.put("childList", AttributeValue.builder().l(nestedChildList).build());
+ putItem.put("level2", AttributeValue.builder().m(level2Map).build());
+
+ WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
+ Map transformed = modification.transformedItem();
+ List writtenNestedList = transformed.get("level2").m().get("childList").l();
+
+ assertThat(transformed, is(notNullValue()));
+ assertThat(writtenNestedList, hasSize(3));
+ assertThat(writtenNestedList.get(0).m().get("time").s(), is(expectedWrittenInstant));
+ assertThat(writtenNestedList.get(1).m().get("time").s(), is(expectedWrittenInstant));
+ assertThat(writtenNestedList.get(2), is(STRING_ELEMENT_AFTER_DOCUMENT_MAPS));
+ }
+
+ @Test
+ public void beforeWrite_nestedRecordHoldsAttributeNotOnSchema_thenThatSubtreeUnchanged() {
+ TableSchema schema = createNestedRecordSchema();
+ NestedRecord record = new NestedRecord()
+ .setId("1")
+ .setAttribute("x")
+ .setNestedRecord(new NestedBeanChild().setChildList(Collections.emptyList()));
+
+ Map putItem = new HashMap<>(schema.itemToMap(record, false));
+ Map nestedRecordMap = new HashMap<>(putItem.get("nestedRecord").m());
+ String attributeNameAbsentFromSchema = "notDescribedByNestedSchema";
+ Map arbitraryNestedMap = Collections.singletonMap("k", AttributeValue.fromS("v"));
+ nestedRecordMap.put(attributeNameAbsentFromSchema, AttributeValue.builder().m(arbitraryNestedMap).build());
+ putItem.put("nestedRecord", AttributeValue.builder().m(nestedRecordMap).build());
+
+ WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
+ Map transformed = modification.transformedItem();
+
+ assertThat(transformed, is(notNullValue()));
+ assertThat(transformed.get("nestedRecord").m().get(attributeNameAbsentFromSchema).m().get("k").s(), is("v"));
+ }
+
+ @Test
+ public void beforeWrite_metadataListsNameWithNoConverter_thenTimestampedOnlyWhereConverterExists() {
+ TableSchema> tableSchema = mock(TableSchema.class);
+ TableMetadata tableMetadata = mock(TableMetadata.class);
+ when(tableSchema.tableMetadata()).thenReturn(tableMetadata);
+ when(tableMetadata.customMetadataObject(eq(AUTO_TS_EXTENSION_METADATA_KEY), eq(Collection.class)))
+ .thenReturn(Optional.of(Arrays.asList("validTs", "missingTs")));
+
+ AttributeConverter> instantConverter =
+ DefaultAttributeConverterProvider.create().converterFor(EnhancedType.of(Instant.class));
+ doReturn(instantConverter).when(tableSchema).converterForAttribute("validTs");
+ doReturn(null).when(tableSchema).converterForAttribute("missingTs");
+
+ Map putItem = new HashMap<>();
+ putItem.put("validTs", AttributeValue.fromS("unset"));
+
+ WriteModification modification = invokeBeforeWriteForPutItem(putItem, tableMetadata, tableSchema);
+ Map transformed = modification.transformedItem();
+
+ assertThat(transformed, is(notNullValue()));
+ assertThat(transformed.get("validTs").s(), is(MOCKED_INSTANT_NOW.toString()));
+ assertThat(transformed.containsKey("missingTs"), is(false));
+ }
+
+ @Test
+ public void beforeWrite_nestedMetadataNamesNonSchemaTimestamp_thenDeclaredInstantAttributeStillWritten() {
+ TableSchema schema = schemaRootWithNestedUnknownMetadataTimestampKey();
+ RootWithNestedUnknownMetadataTimestampKey record = new RootWithNestedUnknownMetadataTimestampKey();
+ record.setId("1");
+ record.setNested(new NestedWithUnknownMetadataTimestampKey());
+
+ Map putItem = new HashMap<>(schema.itemToMap(record, false));
+ WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
+ Map transformed = modification.transformedItem();
+
+ assertThat(transformed, is(notNullValue()));
+ assertThat(transformed.get("nested").m().get("time").s(), is(MOCKED_INSTANT_NOW.toString()));
+ }
+
+ @Test
+ public void beforeWrite_nestedDocumentHasTwoAutoTimestampFields_thenBothMatchClockInstant() {
+ String expectedWrittenInstant = MOCKED_INSTANT_NOW.toString();
+ TableSchema schema = schemaRootWithNestedTwoAutoTimestamps();
+ RootWithNestedTwoAutoTimestamps record = new RootWithNestedTwoAutoTimestamps();
+ record.setId("1");
+ record.setNested(new NestedWithTwoAutoTimestamps());
+
+ Map putItem = new HashMap<>(schema.itemToMap(record, false));
+ WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
+ Map transformed = modification.transformedItem();
+
+ assertThat(transformed, is(notNullValue()));
+ Map nestedMap = transformed.get("nested").m();
+ assertThat(nestedMap.get("primaryTimestamp").s(), is(expectedWrittenInstant));
+ assertThat(nestedMap.get("secondaryTimestamp").s(), is(expectedWrittenInstant));
+ }
+
+ @Test
+ public void beforeWrite_nestedChildListStartsWithPlainString_thenFollowingMapsNotTimestamped() {
+ TableSchema schema = BeanTableSchema.create(NestedBeanWithList.class);
+ NestedBeanChild level2 = new NestedBeanChild();
+ level2.setChildList(Collections.singletonList(new SimpleBeanChild().setId("c1")));
+ NestedBeanWithList record = new NestedBeanWithList().setId("1").setLevel2(level2);
+
+ Map putItem = new HashMap<>(schema.itemToMap(record, false));
+ Map level2Map = new HashMap<>(putItem.get("level2").m());
+ AttributeValue childDocumentBeforeWrite = level2Map.get("childList").l().get(0);
+ List nestedChildList = new ArrayList<>();
+ nestedChildList.add(STRING_ELEMENT_BEFORE_DOCUMENT_MAPS);
+ nestedChildList.add(childDocumentBeforeWrite);
+ level2Map.put("childList", AttributeValue.builder().l(nestedChildList).build());
+ putItem.put("level2", AttributeValue.builder().m(level2Map).build());
+
+ WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
+ Map transformed = modification.transformedItem();
+ List writtenNestedList = transformed.get("level2").m().get("childList").l();
+
+ assertThat(transformed, is(notNullValue()));
+ assertThat(writtenNestedList, hasSize(2));
+ assertThat(writtenNestedList.get(0), is(STRING_ELEMENT_BEFORE_DOCUMENT_MAPS));
+ assertThat(writtenNestedList.get(1), is(childDocumentBeforeWrite));
+ }
+
+ private WriteModification invokeBeforeWriteForPutItem(Map itemAttributes,
+ TableSchema tableSchema) {
+ return invokeBeforeWriteForPutItem(itemAttributes, tableSchema.tableMetadata(), tableSchema);
+ }
+
+ private WriteModification invokeBeforeWriteForPutItem(Map itemAttributes,
+ TableMetadata tableMetadata,
+ TableSchema> tableSchema) {
+ return standaloneExtension.beforeWrite(
+ DefaultDynamoDbExtensionContext.builder()
+ .items(itemAttributes)
+ .tableMetadata(tableMetadata)
+ .tableSchema(tableSchema)
+ .operationName(OperationName.PUT_ITEM)
+ .operationContext(EXTENSION_DIRECT_TEST_OPERATION_CONTEXT)
+ .build());
+ }
+
+ 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());
+ }
+
+
+ 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();
+ }
+
+ 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();
+ }
+
+ 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();
+ }
+
+ 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();
+ }
+
+ 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();
+ }
+
+ 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();
+ }
+
+ private static TableSchema schemaPlainNested() {
+ return StaticTableSchema.builder(PlainNested.class)
+ .newItemSupplier(PlainNested::new)
+ .addAttribute(String.class, a -> a.name("detail")
+ .getter(PlainNested::getDetail)
+ .setter(PlainNested::setDetail))
+ .build();
+ }
+
+ private static TableSchema schemaRootWithPlainNested() {
+ return StaticTableSchema.builder(RootWithPlainNested.class)
+ .newItemSupplier(RootWithPlainNested::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(RootWithPlainNested::getId)
+ .setter(RootWithPlainNested::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(EnhancedType.documentOf(PlainNested.class,
+ schemaPlainNested()),
+ a -> a.name("plainNested")
+ .getter(RootWithPlainNested::getPlainNested)
+ .setter(RootWithPlainNested::setPlainNested))
+ .build();
+ }
+
+ private static TableSchema schemaSimpleBeanChildTimestampOnly() {
+ return StaticTableSchema.builder(SimpleBeanChild.class)
+ .newItemSupplier(SimpleBeanChild::new)
+ .addAttribute(Instant.class, a -> a.name("time")
+ .getter(SimpleBeanChild::getTime)
+ .setter(SimpleBeanChild::setTime)
+ .tags(autoGeneratedTimestampAttribute()))
+ .build();
+ }
+
+ private static TableSchema schemaCompositeNested() {
+ TableSchema timestampedChild = schemaSimpleBeanChildTimestampOnly();
+ return StaticTableSchema.builder(CompositeNested.class)
+ .newItemSupplier(CompositeNested::new)
+ .addAttribute(EnhancedType.documentOf(SimpleBeanChild.class, timestampedChild),
+ a -> a.name("timestampedChild")
+ .getter(CompositeNested::getTimestampedChild)
+ .setter(CompositeNested::setTimestampedChild))
+ .addAttribute(EnhancedType.documentOf(EmptyNested.class,
+ schemaEmptyNested()),
+ a -> a.name("emptyNested")
+ .getter(CompositeNested::getEmptyNested)
+ .setter(CompositeNested::setEmptyNested))
+ .build();
+ }
+
+ private static TableSchema schemaEmptyNested() {
+ return StaticTableSchema.builder(EmptyNested.class)
+ .newItemSupplier(EmptyNested::new)
+ .build();
+ }
+
+ private static TableSchema schemaRootWithCompositeNested() {
+ return StaticTableSchema.builder(RootWithCompositeNested.class)
+ .newItemSupplier(RootWithCompositeNested::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(RootWithCompositeNested::getId)
+ .setter(RootWithCompositeNested::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(EnhancedType.documentOf(CompositeNested.class,
+ schemaCompositeNested()),
+ a -> a.name("nested")
+ .getter(RootWithCompositeNested::getNested)
+ .setter(RootWithCompositeNested::setNested))
+ .build();
+ }
+
+ private static TableSchema schemaNestedBeanWithListChildListOnly() {
+ TableSchema childSchema = schemaSimpleBeanChildTimestampOnly();
+ TableSchema level2Schema =
+ StaticTableSchema.builder(NestedBeanChild.class)
+ .newItemSupplier(NestedBeanChild::new)
+ .addAttribute(EnhancedType.listOf(EnhancedType.documentOf(SimpleBeanChild.class,
+ childSchema)),
+ a -> a.name("childList")
+ .getter(NestedBeanChild::getChildList)
+ .setter(NestedBeanChild::setChildList))
+ .build();
+
+ return StaticTableSchema.builder(NestedBeanWithList.class)
+ .newItemSupplier(NestedBeanWithList::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(NestedBeanWithList::getId)
+ .setter(NestedBeanWithList::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(EnhancedType.documentOf(NestedBeanChild.class, level2Schema),
+ a -> a.name("level2")
+ .getter(NestedBeanWithList::getLevel2)
+ .setter(NestedBeanWithList::setLevel2))
+ .build();
+ }
+
+ private static TableSchema schemaRootWithPlainNestedList() {
+ TableSchema plain = schemaPlainNested();
+ TableSchema nestedPlainListSchema =
+ StaticTableSchema.builder(NestedPlainList.class)
+ .newItemSupplier(NestedPlainList::new)
+ .addAttribute(EnhancedType.listOf(EnhancedType.documentOf(PlainNested.class, plain)),
+ a -> a.name("items")
+ .getter(NestedPlainList::getItems)
+ .setter(NestedPlainList::setItems))
+ .build();
+
+ return StaticTableSchema.builder(RootWithPlainNestedList.class)
+ .newItemSupplier(RootWithPlainNestedList::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(RootWithPlainNestedList::getId)
+ .setter(RootWithPlainNestedList::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(EnhancedType.documentOf(NestedPlainList.class, nestedPlainListSchema),
+ a -> a.name("nested")
+ .getter(RootWithPlainNestedList::getNested)
+ .setter(RootWithPlainNestedList::setNested))
+ .build();
+ }
+
+ private static TableSchema schemaNestedWithUnknownMetadataTimestampKey() {
+ return StaticTableSchema.builder(NestedWithUnknownMetadataTimestampKey.class)
+ .newItemSupplier(NestedWithUnknownMetadataTimestampKey::new)
+ .addAttribute(Instant.class, a -> a.name("time")
+ .getter(NestedWithUnknownMetadataTimestampKey::getTime)
+ .setter(NestedWithUnknownMetadataTimestampKey::setTime)
+ .tags(autoGeneratedTimestampAttribute()))
+ .tags(() -> b -> b.addCustomMetadataObject(AUTO_TS_EXTENSION_METADATA_KEY,
+ Collections.singletonList(
+ "nonexistentTimestampAttribute")))
+ .build();
+ }
+
+ private static TableSchema schemaRootWithNestedUnknownMetadataTimestampKey() {
+ TableSchema nested = schemaNestedWithUnknownMetadataTimestampKey();
+ return StaticTableSchema.builder(RootWithNestedUnknownMetadataTimestampKey.class)
+ .newItemSupplier(RootWithNestedUnknownMetadataTimestampKey::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(RootWithNestedUnknownMetadataTimestampKey::getId)
+ .setter(RootWithNestedUnknownMetadataTimestampKey::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(EnhancedType.documentOf(NestedWithUnknownMetadataTimestampKey.class, nested),
+ a -> a.name("nested")
+ .getter(RootWithNestedUnknownMetadataTimestampKey::getNested)
+ .setter(RootWithNestedUnknownMetadataTimestampKey::setNested))
+ .build();
+ }
+
+ private static TableSchema schemaNestedWithTwoAutoTimestamps() {
+ return StaticTableSchema.builder(NestedWithTwoAutoTimestamps.class)
+ .newItemSupplier(NestedWithTwoAutoTimestamps::new)
+ .addAttribute(Instant.class, a -> a.name("primaryTimestamp")
+ .getter(NestedWithTwoAutoTimestamps::getPrimaryTimestamp)
+ .setter(NestedWithTwoAutoTimestamps::setPrimaryTimestamp)
+ .tags(autoGeneratedTimestampAttribute()))
+ .addAttribute(Instant.class, a -> a.name("secondaryTimestamp")
+ .getter(NestedWithTwoAutoTimestamps::getSecondaryTimestamp)
+ .setter(NestedWithTwoAutoTimestamps::setSecondaryTimestamp)
+ .tags(autoGeneratedTimestampAttribute()))
+ .build();
+ }
+
+ private static TableSchema schemaRootWithNestedTwoAutoTimestamps() {
+ TableSchema nested = schemaNestedWithTwoAutoTimestamps();
+ return StaticTableSchema.builder(RootWithNestedTwoAutoTimestamps.class)
+ .newItemSupplier(RootWithNestedTwoAutoTimestamps::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(RootWithNestedTwoAutoTimestamps::getId)
+ .setter(RootWithNestedTwoAutoTimestamps::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(EnhancedType.documentOf(NestedWithTwoAutoTimestamps.class, nested),
+ a -> a.name("nested")
+ .getter(RootWithNestedTwoAutoTimestamps::getNested)
+ .setter(RootWithNestedTwoAutoTimestamps::setNested))
+ .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);
+ }
+ }
+
+ /**
+ * Plain nested item with no auto-generated timestamp attributes (static schema maps a single string field only).
+ */
+ private static class PlainNested {
+ private String detail;
+
+ public String getDetail() {
+ return detail;
+ }
+
+ public void setDetail(String detail) {
+ this.detail = detail;
+ }
+ }
+
+ private static class RootWithPlainNested {
+ private String id;
+ private PlainNested plainNested;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public PlainNested getPlainNested() {
+ return plainNested;
+ }
+
+ public void setPlainNested(PlainNested plainNested) {
+ this.plainNested = plainNested;
+ }
+ }
+
+ /**
+ * Empty nested map type (maps to an empty DynamoDB attribute map).
+ */
+ private static class EmptyNested {
+ }
+
+ /**
+ * Nested structure with one timestamped child and one empty nested sibling.
+ */
+ private static class CompositeNested {
+ private SimpleBeanChild timestampedChild;
+ private EmptyNested emptyNested;
+
+ public SimpleBeanChild getTimestampedChild() {
+ return timestampedChild;
+ }
+
+ public void setTimestampedChild(SimpleBeanChild timestampedChild) {
+ this.timestampedChild = timestampedChild;
+ }
+
+ public EmptyNested getEmptyNested() {
+ return emptyNested;
+ }
+
+ public void setEmptyNested(EmptyNested emptyNested) {
+ this.emptyNested = emptyNested;
+ }
+ }
+
+ private static class RootWithCompositeNested {
+ private String id;
+ private CompositeNested nested;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public CompositeNested getNested() {
+ return nested;
+ }
+
+ public void setNested(CompositeNested nested) {
+ this.nested = nested;
+ }
+ }
+
+ /**
+ * Nested structure containing a list of plain (non-timestamp) items.
+ */
+ private static class NestedPlainList {
+ private List items;
+
+ public List getItems() {
+ return items;
+ }
+
+ public void setItems(List items) {
+ this.items = items;
+ }
+ }
+
+ private static class RootWithPlainNestedList {
+ private String id;
+ private NestedPlainList nested;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public NestedPlainList getNested() {
+ return nested;
+ }
+
+ public void setNested(NestedPlainList nested) {
+ this.nested = nested;
+ }
+ }
+
+ private static class NestedWithTwoAutoTimestamps {
+ private Instant primaryTimestamp;
+ private Instant secondaryTimestamp;
+
+ public Instant getPrimaryTimestamp() {
+ return primaryTimestamp;
+ }
+
+ public void setPrimaryTimestamp(Instant primaryTimestamp) {
+ this.primaryTimestamp = primaryTimestamp;
+ }
+
+ public Instant getSecondaryTimestamp() {
+ return secondaryTimestamp;
+ }
+
+ public void setSecondaryTimestamp(Instant secondaryTimestamp) {
+ this.secondaryTimestamp = secondaryTimestamp;
+ }
+ }
+
+ private static class RootWithNestedTwoAutoTimestamps {
+ private String id;
+ private NestedWithTwoAutoTimestamps nested;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public NestedWithTwoAutoTimestamps getNested() {
+ return nested;
+ }
+
+ public void setNested(NestedWithTwoAutoTimestamps nested) {
+ this.nested = nested;
+ }
+ }
+
+ private static class NestedWithUnknownMetadataTimestampKey {
+ private Instant time;
+
+ public Instant getTime() {
+ return time;
+ }
+
+ public void setTime(Instant time) {
+ this.time = time;
+ }
+ }
+
+ private static class RootWithNestedUnknownMetadataTimestampKey {
+ private String id;
+ private NestedWithUnknownMetadataTimestampKey nested;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public NestedWithUnknownMetadataTimestampKey getNested() {
+ return nested;
+ }
+
+ public void setNested(NestedWithUnknownMetadataTimestampKey nested) {
+ this.nested = nested;
+ }
+ }
+}
\ 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 5d5ccf4fdb4..00000000000
--- 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 196d3828227..fe87b1ece6e 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 00000000000..527fc44c669
--- /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 6e3bbdbdc9a..0af09b7ca28 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