diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-96aff9e.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-96aff9e.json
new file mode 100644
index 000000000000..cab57e6fb0cf
--- /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 and @DynamoDbUpdateBehavior on attributes within nested objects. The @DynamoDbUpdateBehavior annotation will only take effect for nested attributes when using IgnoreNullsMode.SCALAR_ONLY."
+}
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java
index 2ac27d918202..ae17c451ca76 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,13 +15,21 @@
package software.amazon.awssdk.enhanced.dynamodb.extensions;
+import static software.amazon.awssdk.enhanced.dynamodb.extensions.utility.NestedRecordUtils.getTableSchemaForListElement;
+import static software.amazon.awssdk.enhanced.dynamodb.extensions.utility.NestedRecordUtils.reconstructCompositeKey;
+import static software.amazon.awssdk.enhanced.dynamodb.extensions.utility.NestedRecordUtils.resolveSchemasPerPath;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema;
+
import java.time.Clock;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.function.Consumer;
+import java.util.stream.Collectors;
import software.amazon.awssdk.annotations.NotThreadSafe;
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.annotations.ThreadSafe;
@@ -30,6 +38,7 @@
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext;
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
@@ -64,6 +73,10 @@
*
* 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
@@ -126,26 +139,105 @@ public static AutoGeneratedTimestampRecordExtension create() {
*/
@Override
public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) {
+ Map itemToTransform = new HashMap<>(context.items());
+
+ Map updatedItems = new HashMap<>();
+ Instant currentInstant = clock.instant();
- Collection customMetadataObject = context.tableMetadata()
- .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null);
+ itemToTransform.forEach((key, value) -> {
+ if (value.hasM() && value.m() != null) {
+ Optional extends TableSchema>> nestedSchema = getNestedSchema(context.tableSchema(), key);
+ if (nestedSchema.isPresent()) {
+ Map processed = processNestedObject(value.m(), nestedSchema.get(), currentInstant);
+ updatedItems.put(key, AttributeValue.builder().m(processed).build());
+ }
+ } else if (value.hasL() && !value.l().isEmpty() && value.l().get(0).hasM()) {
+ TableSchema> elementListSchema = getTableSchemaForListElement(context.tableSchema(), key);
+
+ List updatedList = value.l()
+ .stream()
+ .map(listItem -> listItem.hasM() ?
+ AttributeValue.builder()
+ .m(processNestedObject(listItem.m(),
+ elementListSchema,
+ currentInstant))
+ .build() : listItem)
+ .collect(Collectors.toList());
+ updatedItems.put(key, AttributeValue.builder().l(updatedList).build());
+ }
+ });
+
+ Map> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema());
+
+ stringTableSchemaMap.forEach((path, schema) -> {
+ Collection customMetadataObject = schema.tableMetadata()
+ .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class)
+ .orElse(null);
+
+ if (customMetadataObject != null) {
+ customMetadataObject.forEach(
+ key -> insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key),
+ schema.converterForAttribute(key), currentInstant));
+ }
+ });
- if (customMetadataObject == null) {
+ if (updatedItems.isEmpty()) {
return WriteModification.builder().build();
}
- Map itemToTransform = new HashMap<>(context.items());
- customMetadataObject.forEach(
- key -> insertTimestampInItemToTransform(itemToTransform, key,
- context.tableSchema().converterForAttribute(key)));
+
+ itemToTransform.putAll(updatedItems);
+
return WriteModification.builder()
.transformedItem(Collections.unmodifiableMap(itemToTransform))
.build();
}
+ private Map processNestedObject(Map nestedMap, TableSchema> nestedSchema,
+ Instant currentInstant) {
+ Map updatedNestedMap = new HashMap<>(nestedMap);
+ Collection customMetadataObject = nestedSchema.tableMetadata()
+ .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null);
+
+ if (customMetadataObject != null) {
+ customMetadataObject.forEach(
+ key -> insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key),
+ nestedSchema.converterForAttribute(key), currentInstant));
+ }
+
+ nestedMap.forEach((nestedKey, nestedValue) -> {
+ if (nestedValue.hasM()) {
+ updatedNestedMap.put(nestedKey,
+ AttributeValue.builder().m(processNestedObject(nestedValue.m(), nestedSchema,
+ currentInstant)).build());
+ } else if (nestedValue.hasL() && !nestedValue.l().isEmpty()
+ && nestedValue.l().get(0).hasM()) {
+ try {
+ TableSchema> listElementSchema = TableSchema.fromClass(
+ Class.forName(nestedSchema.converterForAttribute(nestedKey)
+ .type().rawClassParameters().get(0).rawClass().getName()));
+ List updatedList = nestedValue
+ .l()
+ .stream()
+ .map(listItem -> listItem.hasM() ?
+ AttributeValue.builder()
+ .m(processNestedObject(listItem.m(),
+ listElementSchema,
+ currentInstant)).build() : listItem)
+ .collect(Collectors.toList());
+ updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build());
+ } catch (ClassNotFoundException e) {
+ throw new IllegalArgumentException("Class not found for field name: " + nestedKey, e);
+ }
+ }
+ });
+ return updatedNestedMap;
+ }
+
private void insertTimestampInItemToTransform(Map itemToTransform,
String key,
- AttributeConverter converter) {
- itemToTransform.put(key, converter.transformFrom(clock.instant()));
+ AttributeConverter converter,
+ Instant instant) {
+ itemToTransform.put(key, converter.transformFrom(instant));
}
/**
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/utility/NestedRecordUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/utility/NestedRecordUtils.java
new file mode 100644
index 000000000000..aa724a3a1442
--- /dev/null
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/utility/NestedRecordUtils.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.enhanced.dynamodb.extensions.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.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+@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 a list attribute from the provided root schema.
+ *
+ * This method is useful when dealing with lists of nested objects in a DynamoDB-enhanced table schema,
+ * particularly in scenarios where the list is part of a flattened nested structure.
+ *
+ * If the provided key contains the nested object delimiter (e.g., {@code _NESTED_ATTR_UPDATE_}), the method traverses
+ * the nested hierarchy based on that path to locate the correct schema for the target attribute.
+ * Otherwise, it directly resolves the list element type from the root schema using reflection.
+ *
+ * @param rootSchema The root {@link TableSchema} representing the top-level entity.
+ * @param key The key representing the list attribute, either flat or nested (using a delimiter).
+ * @return The {@link TableSchema} representing the list element type of the specified attribute.
+ * @throws IllegalArgumentException If the list element class cannot be found via reflection.
+ */
+ public static TableSchema> getTableSchemaForListElement(TableSchema> rootSchema, String key) {
+ TableSchema> listElementSchema;
+ try {
+ if (!key.contains(NESTED_OBJECT_UPDATE)) {
+ listElementSchema = TableSchema.fromClass(
+ Class.forName(rootSchema.converterForAttribute(key).type().rawClassParameters().get(0).rawClass().getName()));
+ } else {
+ String[] parts = NESTED_OBJECT_PATTERN.split(key);
+ TableSchema> currentSchema = rootSchema;
+
+ for (int i = 0; i < parts.length - 1; i++) {
+ Optional extends TableSchema>> nestedSchema = getNestedSchema(currentSchema, parts[i]);
+ if (nestedSchema.isPresent()) {
+ currentSchema = nestedSchema.get();
+ }
+ }
+ String attributeName = parts[parts.length - 1];
+ listElementSchema = TableSchema.fromClass(
+ Class.forName(currentSchema.converterForAttribute(attributeName)
+ .type().rawClassParameters().get(0).rawClass().getName()));
+ }
+ } catch (ClassNotFoundException e) {
+ throw new IllegalArgumentException("Class not found for field name: " + key, e);
+ }
+ return listElementSchema;
+ }
+
+ /**
+ * Traverses the attribute keys representing flattened nested structures and resolves the corresponding
+ * {@link TableSchema} for each nested path.
+ *
+ * The method constructs a mapping between each unique nested path (represented as dot-delimited strings)
+ * and the corresponding {@link TableSchema} object derived from the root schema. It supports resolving schemas
+ * for arbitrarily deep nesting, using the {@code _NESTED_ATTR_UPDATE_} pattern as a path delimiter.
+ *
+ * This is typically used in update or transformation flows where fields from nested objects are represented
+ * as flattened keys in the attribute map (e.g., {@code parent_NESTED_ATTR_UPDATE_child}).
+ *
+ * @param attributesToSet A map of flattened attribute keys to values, where keys may represent paths to nested attributes.
+ * @param rootSchema The root {@link TableSchema} of the top-level entity.
+ * @return A map where the key is the nested path (e.g., {@code "parent.child"}) and the value is the {@link TableSchema}
+ * corresponding to that level in the object hierarchy.
+ */
+ public static Map> resolveSchemasPerPath(Map attributesToSet,
+ TableSchema> rootSchema) {
+ Map> schemaMap = new HashMap<>();
+ schemaMap.put("", rootSchema);
+
+ for (String key : attributesToSet.keySet()) {
+ String[] parts = NESTED_OBJECT_PATTERN.split(key);
+
+ StringBuilder pathBuilder = new StringBuilder();
+ TableSchema> currentSchema = rootSchema;
+
+ for (int i = 0; i < parts.length - 1; i++) {
+ if (pathBuilder.length() > 0) {
+ pathBuilder.append(".");
+ }
+ pathBuilder.append(parts[i]);
+
+ String path = pathBuilder.toString();
+
+ if (!schemaMap.containsKey(path)) {
+ Optional extends TableSchema>> nestedSchema = getNestedSchema(currentSchema, parts[i]);
+ if (nestedSchema.isPresent()) {
+ schemaMap.put(path, nestedSchema.get());
+ currentSchema = nestedSchema.get();
+ }
+ } else {
+ currentSchema = schemaMap.get(path);
+ }
+ }
+ }
+ return schemaMap;
+ }
+
+ public static String reconstructCompositeKey(String path, String attributeName) {
+ if (path == null || path.isEmpty()) {
+ return attributeName;
+ }
+ return String.join(NESTED_OBJECT_UPDATE, path.split("\\."))
+ + NESTED_OBJECT_UPDATE + attributeName;
+ }
+}
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java
index 61d750e98a7e..abb85f532559 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
@@ -204,4 +204,15 @@ public static List getItemsFromSupplier(List> itemSupplierLis
public static boolean isNullAttributeValue(AttributeValue attributeValue) {
return attributeValue.nul() != null && attributeValue.nul();
}
+
+ /**
+ * Retrieves the {@link TableSchema} for a nested attribute within the given parent schema.
+ *
+ * @param parentSchema the schema of the parent bean class
+ * @param attributeName the name of the nested attribute
+ * @return an {@link Optional} containing the nested attribute's {@link TableSchema}, or empty if unavailable
+ */
+ public static Optional extends TableSchema>> getNestedSchema(TableSchema> parentSchema, String attributeName) {
+ return parentSchema.converterForAttribute(attributeName).type().tableSchema();
+ }
}
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java
index 0ffe361b5aed..c775b31cdf77 100644
--- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java
@@ -131,8 +131,7 @@ public UpdateItemRequest generateRequest(TableSchema tableSchema,
Map keyAttributes = filterMap(itemMap, entry -> primaryKeys.contains(entry.getKey()));
Map nonKeyAttributes = filterMap(itemMap, entry -> !primaryKeys.contains(entry.getKey()));
-
- Expression updateExpression = generateUpdateExpressionIfExist(tableMetadata, transformation, nonKeyAttributes);
+ Expression updateExpression = generateUpdateExpressionIfExist(tableSchema, transformation, nonKeyAttributes);
Expression conditionExpression = generateConditionExpressionIfExist(transformation, request);
Map expressionNames = coalesceExpressionNames(updateExpression, conditionExpression);
@@ -275,7 +274,7 @@ public TransactWriteItem generateTransactWriteItem(TableSchema tableSchema, O
* if there are attributes to be updated (most likely). If both exist, they are merged and the code generates a final
* Expression that represent the result.
*/
- private Expression generateUpdateExpressionIfExist(TableMetadata tableMetadata,
+ private Expression generateUpdateExpressionIfExist(TableSchema tableSchema,
WriteModification transformation,
Map attributes) {
UpdateExpression updateExpression = null;
@@ -284,7 +283,7 @@ private Expression generateUpdateExpressionIfExist(TableMetadata tableMetadata,
}
if (!attributes.isEmpty()) {
List nonRemoveAttributes = UpdateExpressionConverter.findAttributeNames(updateExpression);
- UpdateExpression operationUpdateExpression = operationExpression(attributes, tableMetadata, nonRemoveAttributes);
+ UpdateExpression operationUpdateExpression = operationExpression(attributes, tableSchema, nonRemoveAttributes);
if (updateExpression == null) {
updateExpression = operationUpdateExpression;
} else {
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java
index 1d47400ab2e6..4ad1989d057d 100644
--- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java
@@ -15,21 +15,24 @@
package software.amazon.awssdk.enhanced.dynamodb.internal.update;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema;
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue;
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef;
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef;
import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE;
import static software.amazon.awssdk.utils.CollectionUtils.filterMap;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import software.amazon.awssdk.annotations.SdkInternalApi;
-import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils;
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.UpdateBehaviorTag;
import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior;
@@ -57,12 +60,12 @@ public static String ifNotExists(String key, String initValue) {
* Generates an UpdateExpression representing a POJO, with only SET and REMOVE actions.
*/
public static UpdateExpression operationExpression(Map itemMap,
- TableMetadata tableMetadata,
+ TableSchema tableSchema,
List nonRemoveAttributes) {
Map setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue()));
UpdateExpression setAttributeExpression = UpdateExpression.builder()
- .actions(setActionsFor(setAttributes, tableMetadata))
+ .actions(setActionsFor(setAttributes, tableSchema))
.build();
Map removeAttributes =
@@ -78,13 +81,31 @@ public static UpdateExpression operationExpression(Map i
/**
* Creates a list of SET actions for all attributes supplied in the map.
*/
- private static List setActionsFor(Map attributesToSet, TableMetadata tableMetadata) {
- return attributesToSet.entrySet()
- .stream()
- .map(entry -> setValue(entry.getKey(),
- entry.getValue(),
- UpdateBehaviorTag.resolveForAttribute(entry.getKey(), tableMetadata)))
- .collect(Collectors.toList());
+ private static List setActionsFor(Map attributesToSet, TableSchema tableSchema) {
+ List actions = new ArrayList<>();
+ for (Map.Entry entry : attributesToSet.entrySet()) {
+ String key = entry.getKey();
+ AttributeValue value = entry.getValue();
+
+ if (key.contains(NESTED_OBJECT_UPDATE)) {
+ TableSchema currentSchema = tableSchema;
+ List pathFieldNames = Arrays.asList(PATTERN.split(key));
+ String attributeName = pathFieldNames.get(pathFieldNames.size() - 1);
+
+ for (int i = 0; i < pathFieldNames.size() - 1; i++) {
+ Optional extends TableSchema>> nestedSchema = getNestedSchema(currentSchema, pathFieldNames.get(i));
+ if (nestedSchema.isPresent()) {
+ currentSchema = nestedSchema.get();
+ }
+ }
+
+ actions.add(setValue(key, value,
+ UpdateBehaviorTag.resolveForAttribute(attributeName, currentSchema.tableMetadata())));
+ } else {
+ actions.add(setValue(key, value, UpdateBehaviorTag.resolveForAttribute(key, tableSchema.tableMetadata())));
+ }
+ }
+ return actions;
}
/**
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbUpdateBehavior.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbUpdateBehavior.java
index fa161446c1a4..d14216b6a529 100644
--- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbUpdateBehavior.java
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbUpdateBehavior.java
@@ -22,10 +22,15 @@
import software.amazon.awssdk.annotations.SdkPublicApi;
import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.BeanTableSchemaAttributeTags;
import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior;
+import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode;
/**
* Specifies the behavior when this attribute is updated as part of an 'update' operation such as UpdateItem. See
* documentation of {@link UpdateBehavior} for details on the different behaviors supported and the default behavior.
+ * For attributes within nested objects, this annotation is only respected when the request uses
+ * {@link IgnoreNullsMode#SCALAR_ONLY}. In {@link IgnoreNullsMode#MAPS_ONLY} or {@link IgnoreNullsMode#DEFAULT},
+ * the annotation has no effect. When applied to a list of nested objects, the annotation is not supported,
+ * as individual elements cannot be updated — the entire list is replaced during an update operation.
*/
@SdkPublicApi
@Target({ElementType.METHOD})
diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/NestedRecordUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/NestedRecordUtilsTest.java
new file mode 100644
index 000000000000..484819c04db1
--- /dev/null
+++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/extensions/NestedRecordUtilsTest.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.enhanced.dynamodb.extensions;
+
+import static software.amazon.awssdk.enhanced.dynamodb.extensions.utility.NestedRecordUtils.getTableSchemaForListElement;
+import static software.amazon.awssdk.enhanced.dynamodb.extensions.utility.NestedRecordUtils.resolveSchemasPerPath;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordListElement;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordWithUpdateBehavior;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithUpdateBehaviors;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+public class NestedRecordUtilsTest {
+
+ @Test
+ public void getTableSchemaForListElement_shouldReturnElementSchema() {
+ TableSchema parentSchema = TableSchema.fromBean(NestedRecordWithUpdateBehavior.class);
+
+ TableSchema> childSchema = getTableSchemaForListElement(parentSchema, "nestedRecordList");
+
+ Assertions.assertNotNull(childSchema);
+ Assertions.assertEquals(TableSchema.fromBean(NestedRecordListElement.class), childSchema);
+ }
+
+ @Test
+ public void resolveSchemasPerPath_shouldResolveNestedPaths() {
+ TableSchema rootSchema = TableSchema.fromBean(RecordWithUpdateBehaviors.class);
+
+ Map attributesToSet = new HashMap<>();
+ attributesToSet.put("nestedRecord_NESTED_ATTR_UPDATE_nestedRecord_NESTED_ATTR_UPDATE_attribute",
+ AttributeValue.builder().s("attributeValue").build());
+
+ Map> result = resolveSchemasPerPath(attributesToSet, rootSchema);
+
+ Assertions.assertEquals(3, result.size());
+ Assertions.assertTrue(result.containsKey(""));
+ Assertions.assertTrue(result.containsKey("nestedRecord"));
+ Assertions.assertTrue(result.containsKey("nestedRecord.nestedRecord"));
+ }
+}
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
index 5d5ccf4fdb4b..b07b01814c4b 100644
--- 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
@@ -15,7 +15,6 @@
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;
@@ -27,11 +26,9 @@
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;
@@ -40,15 +37,12 @@
import org.mockito.Mockito;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
import software.amazon.awssdk.enhanced.dynamodb.Expression;
-import software.amazon.awssdk.enhanced.dynamodb.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;
@@ -66,14 +60,9 @@ public class AutoGeneratedTimestampRecordTest extends LocalDynamoDbSyncTestBase
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)
@@ -83,6 +72,18 @@ public class AutoGeneratedTimestampRecordTest extends LocalDynamoDbSyncTestBase
.tags(autoGeneratedTimestampAttribute()))
.build();
+ private static final TableSchema NESTED_TABLE_SCHEMA =
+ StaticTableSchema.builder(NestedRecord.class)
+ .newItemSupplier(NestedRecord::new)
+ .addAttribute(Instant.class, a -> a.name("nestedTimeAttribute")
+ .getter(NestedRecord::getNestedTimeAttribute)
+ .setter(NestedRecord::setNestedTimeAttribute)
+ .tags(autoGeneratedTimestampAttribute()))
+ .addAttribute(String.class, a -> a.name("nestedAttribute")
+ .getter(NestedRecord::getNestedAttribute)
+ .setter(NestedRecord::setNestedAttribute))
+ .build();
+
private static final TableSchema TABLE_SCHEMA =
StaticTableSchema.builder(Record.class)
.newItemSupplier(Record::new)
@@ -113,13 +114,13 @@ public class AutoGeneratedTimestampRecordTest extends LocalDynamoDbSyncTestBase
.attributeConverter(TimeFormatUpdateTestConverter.create())
.tags(autoGeneratedTimestampAttribute()))
.flatten(FLATTENED_TABLE_SCHEMA, Record::getFlattenedRecord, Record::setFlattenedRecord)
+ .addAttribute(EnhancedType.documentOf(NestedRecord.class,
+ NESTED_TABLE_SCHEMA,
+ b -> b.ignoreNulls(true)),
+ a -> a.name("nestedRecord").getter(Record::getNestedRecord)
+ .setter(Record::setNestedRecord))
.build();
- private final List