diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-3b3908c.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-3b3908c.json
new file mode 100644
index 000000000000..8270f7ab107f
--- /dev/null
+++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-3b3908c.json
@@ -0,0 +1,6 @@
+{
+ "type": "feature",
+ "category": "Amazon DynamoDB Enhanced Client",
+ "contributor": "",
+ "description": "Added support for @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..62059b01fa61 100644
--- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java
@@ -15,12 +15,21 @@
package software.amazon.awssdk.enhanced.dynamodb.extensions;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.hasMap;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.getTableSchemaForListElement;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.reconstructCompositeKey;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.resolveSchemasPerPath;
+
import java.time.Clock;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
import java.util.function.Consumer;
import software.amazon.awssdk.annotations.NotThreadSafe;
import software.amazon.awssdk.annotations.SdkPublicApi;
@@ -30,6 +39,7 @@
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext;
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag;
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
@@ -64,10 +74,23 @@
*
* Every time a new update of the record is successfully written to the database, the timestamp at which it was modified will
* be automatically updated. This extension applies the conversions as defined in the attribute convertor.
+ * The implementation handles both flattened nested parameters (identified by keys separated with
+ * {@code "_NESTED_ATTR_UPDATE_"}) and entire nested maps or lists, ensuring consistent behavior across both representations.
+ * If a nested object or list is {@code null}, no timestamp values will be generated for any of its annotated fields.
+ * The same timestamp value is used for both top-level attributes and all applicable nested fields.
+ *
+ *
+ * Note: This implementation uses a temporary cache keyed by {@link TableSchema} instance.
+ * When updating timestamps in nested objects or lists, the correct {@code TableSchema} must be used for each object.
+ * This cache ensures that each nested object is processed with its own schema, avoiding redundant lookups and ensuring
+ * all annotated timestamp fields are updated correctly.
+ *
*/
@SdkPublicApi
@ThreadSafe
public final class AutoGeneratedTimestampRecordExtension implements DynamoDbEnhancedClientExtension {
+
+ private static final String NESTED_OBJECT_UPDATE = "_NESTED_ATTR_UPDATE_";
private static final String CUSTOM_METADATA_KEY = "AutoGeneratedTimestampExtension:AutoGeneratedTimestampAttribute";
private static final AutoGeneratedTimestampAttribute
AUTO_GENERATED_TIMESTAMP_ATTRIBUTE = new AutoGeneratedTimestampAttribute();
@@ -126,26 +149,195 @@ 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();
+
+ // Use TableSchema> instance as the cache key
+ Map, TableSchema>> schemaInstanceCache = new HashMap<>();
+
+ itemToTransform.forEach((key, value) -> {
+ if (hasMap(value)) {
+ Optional extends TableSchema>> nestedSchemaOpt = getNestedSchema(context.tableSchema(), key);
+ if (nestedSchemaOpt.isPresent()) {
+ TableSchema> nestedSchema = nestedSchemaOpt.get();
+ TableSchema> cachedSchema = getOrCacheSchema(schemaInstanceCache, nestedSchema);
+ Map processed =
+ processNestedObject(value.m(), cachedSchema, currentInstant, schemaInstanceCache);
+ updatedItems.put(key, AttributeValue.builder().m(processed).build());
+ }
+ } else if (value.hasL() && !value.l().isEmpty()) {
+ // Check first non-null element to determine if this is a list of maps
+ AttributeValue firstElement = value.l().stream()
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
+
+ if (hasMap(firstElement)) {
+ TableSchema> elementListSchema = getTableSchemaForListElement(context.tableSchema(), key);
+ if (elementListSchema != null) {
+ TableSchema> cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema);
- Collection customMetadataObject = context.tableMetadata()
- .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null);
+ Collection updatedList = null;
+ for (AttributeValue listItem : value.l()) {
+ if (updatedList == null) {
+ updatedList = new ArrayList<>(value.l().size());
+ }
- if (customMetadataObject == null) {
+ if (hasMap(listItem)) {
+ updatedList.add(AttributeValue.builder()
+ .m(processNestedObject(
+ listItem.m(),
+ cachedSchema,
+ currentInstant,
+ schemaInstanceCache))
+ .build());
+ } else {
+ updatedList.add(listItem);
+ }
+ }
+
+ if (updatedList != null) {
+ updatedItems.put(key, AttributeValue.builder().l(updatedList).build());
+ }
+ }
+ }
+ }
+ });
+
+ Map> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema());
+
+ stringTableSchemaMap.forEach((path, schema) -> {
+ Collection customMetadataObject = schema.tableMetadata()
+ .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class)
+ .orElse(null);
+
+ if (customMetadataObject != null) {
+ customMetadataObject.forEach(
+ key -> {
+ AttributeConverter> converter = schema.converterForAttribute(key);
+ if (converter != null) {
+ insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key),
+ converter, currentInstant);
+ }
+ });
+ }
+ });
+
+ if (updatedItems.isEmpty()) {
return WriteModification.builder().build();
}
- Map itemToTransform = new HashMap<>(context.items());
- customMetadataObject.forEach(
- key -> insertTimestampInItemToTransform(itemToTransform, key,
- context.tableSchema().converterForAttribute(key)));
+
+ itemToTransform.putAll(updatedItems);
+
return WriteModification.builder()
.transformedItem(Collections.unmodifiableMap(itemToTransform))
.build();
}
+ private static TableSchema> getOrCacheSchema(
+ Map, TableSchema>> schemaInstanceCache, TableSchema> schema) {
+
+ TableSchema> cachedSchema = schemaInstanceCache.get(schema);
+ if (cachedSchema == null) {
+ schemaInstanceCache.put(schema, schema);
+ cachedSchema = schema;
+ }
+ return cachedSchema;
+ }
+
+ private Map processNestedObject(Map nestedMap, TableSchema> nestedSchema,
+ Instant currentInstant,
+ Map, TableSchema>> schemaInstanceCache) {
+ Map updatedNestedMap = nestedMap;
+ boolean updated = false;
+
+ Collection customMetadataObject = nestedSchema.tableMetadata()
+ .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class)
+ .orElse(null);
+
+ if (customMetadataObject != null) {
+ for (String key : customMetadataObject) {
+ AttributeConverter> converter = nestedSchema.converterForAttribute(key);
+ if (converter != null) {
+ if (!updated) {
+ updatedNestedMap = new HashMap<>(nestedMap);
+ updated = true;
+ }
+ insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key),
+ converter, currentInstant);
+ }
+ }
+ }
+
+ for (Map.Entry entry : nestedMap.entrySet()) {
+ String nestedKey = entry.getKey();
+ AttributeValue nestedValue = entry.getValue();
+ if (nestedValue.hasM()) {
+ Optional extends TableSchema>> childSchemaOpt = getNestedSchema(nestedSchema, nestedKey);
+ if (childSchemaOpt.isPresent()) {
+ TableSchema> childSchema = childSchemaOpt.get();
+ TableSchema> cachedSchema = getOrCacheSchema(schemaInstanceCache, childSchema);
+ Map processed = processNestedObject(
+ nestedValue.m(), cachedSchema, currentInstant, schemaInstanceCache);
+
+ if (!Objects.equals(processed, nestedValue.m())) {
+ if (!updated) {
+ updatedNestedMap = new HashMap<>(nestedMap);
+ updated = true;
+ }
+ updatedNestedMap.put(nestedKey, AttributeValue.builder().m(processed).build());
+ }
+ }
+ } else if (nestedValue.hasL() && !nestedValue.l().isEmpty()) {
+ // Check first non-null element to determine if this is a list of maps
+ AttributeValue firstElement = nestedValue.l().stream()
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
+ if (hasMap(firstElement)) {
+ TableSchema> listElementSchema = getTableSchemaForListElement(nestedSchema, nestedKey);
+ if (listElementSchema != null) {
+ TableSchema> cachedSchema = getOrCacheSchema(schemaInstanceCache, listElementSchema);
+ Collection updatedList = new ArrayList<>(nestedValue.l().size());
+ boolean listModified = false;
+ for (AttributeValue listItem : nestedValue.l()) {
+ if (hasMap(listItem)) {
+ AttributeValue updatedItem = AttributeValue.builder()
+ .m(processNestedObject(
+ listItem.m(),
+ cachedSchema,
+ currentInstant,
+ schemaInstanceCache))
+ .build();
+ updatedList.add(updatedItem);
+ if (!Objects.equals(updatedItem, listItem)) {
+ listModified = true;
+ }
+ } else {
+ updatedList.add(listItem);
+ }
+ }
+ if (listModified) {
+ if (!updated) {
+ updatedNestedMap = new HashMap<>(nestedMap);
+ updated = true;
+ }
+ updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build());
+ }
+ }
+ }
+ }
+ }
+ return updatedNestedMap;
+ }
+
private void insertTimestampInItemToTransform(Map itemToTransform,
String key,
- AttributeConverter converter) {
- itemToTransform.put(key, converter.transformFrom(clock.instant()));
+ AttributeConverter converter,
+ Instant instant) {
+ itemToTransform.put(key, converter.transformFrom(instant));
}
/**
@@ -190,6 +382,7 @@ public void validateType(String attributeName, EnhancedType type,
Validate.notNull(type, "type is null");
Validate.notNull(type.rawClass(), "rawClass is null");
Validate.notNull(attributeValueType, "attributeValueType is null");
+ validateAttributeName(attributeName);
if (!type.rawClass().equals(Instant.class)) {
throw new IllegalArgumentException(String.format(
@@ -204,5 +397,15 @@ public Consumer modifyMetadata(String attributeName
return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, Collections.singleton(attributeName))
.markAttributeAsKey(attributeName, attributeValueType);
}
+
+ private static void validateAttributeName(String attributeName) {
+ if (attributeName.contains(NESTED_OBJECT_UPDATE)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Attribute name '%s' contains reserved marker '%s' and is not allowed.",
+ attributeName,
+ NESTED_OBJECT_UPDATE));
+ }
+ }
}
}
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java
index 61d750e98a7e..2690f1d27698 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 extends TableSchema>> getNestedSchema(TableSchema> parentSchema, CharSequence attributeName) {
+ if (parentSchema == null) {
+ throw new IllegalArgumentException("Parent schema cannot be null.");
+ }
+ if (StringUtils.isEmpty(attributeName)) {
+ throw new IllegalArgumentException("Attribute name cannot be null or empty.");
+ }
+
+ AttributeConverter> converter = parentSchema.converterForAttribute(attributeName);
+ if (converter == null) {
+ return Optional.empty();
+ }
+
+ EnhancedType> enhancedType = converter.type();
+ if (enhancedType == null) {
+ return Optional.empty();
+ }
+
+ List> rawClassParameters = enhancedType.rawClassParameters();
+ if (!CollectionUtils.isNullOrEmpty(rawClassParameters)) {
+ enhancedType = rawClassParameters.get(0);
+ }
+
+ return enhancedType.tableSchema();
+ }
}
diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java
new file mode 100644
index 000000000000..0cdfa3577922
--- /dev/null
+++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility;
+
+import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema;
+import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
+import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.utils.CollectionUtils;
+import software.amazon.awssdk.utils.StringUtils;
+
+@SdkInternalApi
+public final class NestedRecordUtils {
+
+ private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE);
+
+ private NestedRecordUtils() {
+ }
+
+ /**
+ * Resolves and returns the {@link TableSchema} for the element type of list attribute from the provided root schema.
+ *
+ * This method is useful when dealing with lists of nested objects in a DynamoDB-enhanced table schema, particularly in
+ * scenarios where the list is part of a flattened nested structure.
+ *
+ * If the provided key contains the nested object delimiter (e.g., {@code _NESTED_ATTR_UPDATE_}), the method traverses the
+ * nested hierarchy based on that path to locate the correct schema for the target attribute. Otherwise, it directly resolves
+ * the list element type from the root schema using reflection.
+ *
+ * @param rootSchema The root {@link TableSchema} representing the top-level entity.
+ * @param key The key representing the list attribute, either flat or nested (using a delimiter).
+ * @return The {@link TableSchema} representing the list element type of the specified attribute.
+ * @throws IllegalArgumentException If the list element class cannot be found via reflection.
+ */
+ public static TableSchema> getTableSchemaForListElement(TableSchema> rootSchema, String key) {
+ TableSchema> listElementSchema;
+ try {
+ if (!key.contains(NESTED_OBJECT_UPDATE)) {
+ Optional extends TableSchema>> staticSchema = getNestedSchema(rootSchema, key);
+ if (staticSchema.isPresent()) {
+ listElementSchema = staticSchema.get();
+ } else {
+ AttributeConverter> converter = rootSchema.converterForAttribute(key);
+ if (converter == null) {
+ throw new IllegalArgumentException("No converter found for attribute: " + key);
+ }
+ List> rawClassParameters = converter.type().rawClassParameters();
+ if (CollectionUtils.isNullOrEmpty(rawClassParameters)) {
+ throw new IllegalArgumentException("No type parameters found for list attribute: " + key);
+ }
+ listElementSchema = TableSchema.fromClass(
+ Class.forName(rawClassParameters.get(0).rawClass().getName()));
+ }
+
+ } else {
+ String[] parts = NESTED_OBJECT_PATTERN.split(key);
+ TableSchema> currentSchema = rootSchema;
+
+ for (int i = 0; i < parts.length - 1; i++) {
+ Optional extends TableSchema>> nestedSchema = getNestedSchema(currentSchema, parts[i]);
+ if (nestedSchema.isPresent()) {
+ currentSchema = nestedSchema.get();
+ }
+ }
+ String attributeName = parts[parts.length - 1];
+ Optional extends TableSchema>> nestedListSchema = getNestedSchema(currentSchema, attributeName);
+ listElementSchema = nestedListSchema
+ .orElseThrow(() -> new IllegalArgumentException("Unable to resolve schema for list element at: " + key));
+ }
+ } catch (ClassNotFoundException e) {
+ throw new IllegalArgumentException("Class not found for field name: " + key, e);
+ }
+ return listElementSchema;
+ }
+
+ /**
+ * Traverses the attribute keys representing flattened nested structures and resolves the corresponding {@link TableSchema}
+ * for each nested path.
+ *
+ * The method constructs a mapping between each unique nested path (represented as dot-delimited strings) and the
+ * corresponding {@link TableSchema} object derived from the root schema. It supports resolving schemas for arbitrarily deep
+ * nesting, using the {@code _NESTED_ATTR_UPDATE_} pattern as a path delimiter.
+ *
+ * This is typically used in update or transformation flows where fields from nested objects are represented as flattened keys
+ * in the attribute map (e.g., {@code parent_NESTED_ATTR_UPDATE_child}).
+ *
+ * @param attributesToSet A map of flattened attribute keys to values, where keys may represent paths to nested attributes.
+ * @param rootSchema The root {@link TableSchema} of the top-level entity.
+ * @return A map where the key is the nested path (e.g., {@code "parent.child"}) and the value is the {@link TableSchema}
+ * corresponding to that level in the object hierarchy.
+ */
+ public static Map> resolveSchemasPerPath(Map attributesToSet,
+ TableSchema> rootSchema) {
+ Map> schemaMap = new HashMap<>();
+ schemaMap.put("", rootSchema);
+
+ for (String key : attributesToSet.keySet()) {
+ String[] parts = NESTED_OBJECT_PATTERN.split(key);
+
+ StringBuilder pathBuilder = new StringBuilder();
+ TableSchema> currentSchema = rootSchema;
+
+ for (int i = 0; i < parts.length - 1; i++) {
+ if (pathBuilder.length() > 0) {
+ pathBuilder.append(".");
+ }
+ pathBuilder.append(parts[i]);
+
+ String path = pathBuilder.toString();
+
+ if (!schemaMap.containsKey(path)) {
+ Optional extends TableSchema>> nestedSchema = getNestedSchema(currentSchema, parts[i]);
+ if (nestedSchema.isPresent()) {
+ schemaMap.put(path, nestedSchema.get());
+ currentSchema = nestedSchema.get();
+ }
+ } else {
+ currentSchema = schemaMap.get(path);
+ }
+ }
+ }
+ return schemaMap;
+ }
+
+ /**
+ * Converts a dot-separated path to a composite key using nested object delimiters. Example:
+ * {@code reconstructCompositeKey("parent.child", "attr")} returns
+ * {@code "parent_NESTED_ATTR_UPDATE_child_NESTED_ATTR_UPDATE_attr"}
+ *
+ * @param path the dot-separated path; may be null or empty
+ * @param attributeName the attribute name to append; must not be null
+ * @return the composite key with nested object delimiters
+ */
+ public static String reconstructCompositeKey(String path, String attributeName) {
+ if (attributeName == null) {
+ throw new IllegalArgumentException("Attribute name cannot be null");
+ }
+
+ if (StringUtils.isEmpty(path)) {
+ return attributeName;
+ }
+
+ return String.join(NESTED_OBJECT_UPDATE, path.split("\\."))
+ + NESTED_OBJECT_UPDATE + attributeName;
+ }
+}
diff --git a/services-custom/dynamodb-enhanced/src/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..3b089446736a 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
@@ -132,7 +132,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);
@@ -171,6 +171,7 @@ public Map transformItemToMapForUpdateExpression(Map nestedAttributes = new HashMap<>();
itemToMap.forEach((key, value) -> {
+ validateAttributeName(key);
if (value.hasM() && isNotEmptyMap(value.m())) {
nestedAttributes.put(key, value);
}
@@ -192,8 +193,9 @@ private Map nestedItemToMap(Map
String key,
AttributeValue attributeValue) {
attributeValue.m().forEach((mapKey, mapValue) -> {
- String nestedAttributeKey = key + NESTED_OBJECT_UPDATE + mapKey;
+ validateAttributeName(mapKey);
if (attributeValueNonNullOrShouldWriteNull(mapValue)) {
+ String nestedAttributeKey = key + NESTED_OBJECT_UPDATE + mapKey;
if (mapValue.hasM()) {
nestedItemToMap(itemToMap, nestedAttributeKey, mapValue);
} else {
@@ -275,7 +277,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 +286,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 {
@@ -355,4 +357,12 @@ private static Map coalesceExpressionValues(Expression f
}
return expressionValues;
}
+
+ 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/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..07e71e00c38e 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,19 @@
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.
+ * 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.
+ *
+ * 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/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/NestedUpdateBehaviorTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/NestedUpdateBehaviorTest.java
new file mode 100644
index 000000000000..39a0670891ac
--- /dev/null
+++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/NestedUpdateBehaviorTest.java
@@ -0,0 +1,478 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedStaticChildRecord;
+import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticImmutableSchemaForNestedRecord;
+import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticImmutableSchemaForSimpleRecord;
+import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticImmutableSchema_NoChildSchemaDefined;
+import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticSchemaForNestedRecord;
+import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticSchemaForNestedRecord_NoChildSchemaDefined;
+import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticSchemaForSimpleRecord;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedBean;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedImmutableRecord;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedStaticRecord;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleBean;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleImmutableRecord;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleStaticRecord;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.ImmutableTableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode;
+import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException;
+
+public class NestedUpdateBehaviorTest extends LocalDynamoDbSyncTestBase {
+
+ private static final String TIMESTAMP_STRING = "2019-01-13T14:00:00Z";
+ private static final String BASE_TABLE_NAME = "update-behavior-test";
+ private static final Clock mockClock = Mockito.mock(Clock.class);
+ private static final Instant MOCKED_INSTANT_NOW = Instant.now(Clock.fixed(Instant.parse(TIMESTAMP_STRING), ZoneOffset.UTC));
+
+ private DynamoDbEnhancedClient enhancedClient;
+ private String currentTestTableName;
+
+ @Before
+ public void beforeClass() {
+ Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_NOW);
+ enhancedClient = DynamoDbEnhancedClient.builder()
+ .dynamoDbClient(getDynamoDbClient())
+ .extensions(AutoGeneratedTimestampRecordExtension.builder()
+ .baseClock(mockClock)
+ .build())
+ .build();
+ }
+
+ @After
+ public void deleteTable() {
+ try {
+ if (currentTestTableName != null) {
+ getDynamoDbClient().deleteTable(r -> r.tableName(currentTestTableName));
+ }
+ } catch (ResourceNotFoundException e) {
+ // Table didn't get created, ignore.
+ }
+ }
+
+ @Test
+ public void beanSchema_simpleRecord_updateBehavior_isRespected() {
+ TableSchema schema = BeanTableSchema.create(SimpleBean.class);
+
+ SimpleBean item = UpdateBehaviorTestModels.createSimpleBean();
+
+ DynamoDbTable table = createAndPut("simple-bean-update-behavior", schema, item);
+
+ SimpleBean result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+ assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways");
+ assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce");
+ assertThat(result.getChildList()).hasSize(2);
+
+ // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields
+ // Note: We only update root-level attributes, not child list items, because
+ // list updates replace the entire list, overriding individual child update behaviors
+ result.setWriteAlwaysField("updated_writeAlways");
+ result.setWriteOnceField("updated_writeOnce");
+
+ table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ SimpleBean updated = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ // Root level assertions
+ assertThat(updated.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change
+ assertThat(updated.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change
+
+ // Child list should remain unchanged since we didn't update it
+ assertThat(updated.getChildList()).hasSize(2);
+ assertThat(updated.getChildList().get(0).getChildAlwaysUpdate()).isEqualTo("child1_initial_writeAlways");
+ assertThat(updated.getChildList().get(0).getChildWriteOnce()).isEqualTo("child1_initial_writeOnce");
+ assertThat(updated.getChildList().get(1).getChildAlwaysUpdate()).isEqualTo("child2_initial_writeAlways");
+ assertThat(updated.getChildList().get(1).getChildWriteOnce()).isEqualTo("child2_initial_writeOnce");
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void beanSchema_nestedRecord_updateBehavior_isRespected() {
+ TableSchema schema = BeanTableSchema.create(NestedBean.class);
+
+ NestedBean item = UpdateBehaviorTestModels.createNestedBean();
+
+ DynamoDbTable table = createAndPut("nested-bean-update-behavior", schema, item);
+
+ NestedBean result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+ assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways");
+ assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce");
+ assertThat(result.getChild()).isNotNull();
+ assertThat(result.getChild().getChildAlwaysUpdate()).isEqualTo("child_initial_writeAlways");
+ assertThat(result.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce");
+
+ // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields
+ result.setWriteAlwaysField("updated_writeAlways");
+ result.setWriteOnceField("updated_writeOnce");
+ result.getChild().setChildAlwaysUpdate("child_updated_writeAlways");
+ result.getChild().setChildWriteOnce("child_updated_writeOnce");
+
+ table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ NestedBean updated = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ // Root level assertions
+ assertThat(updated.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change
+ assertThat(updated.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change
+
+ // Child level assertions
+ assertThat(updated.getChild().getChildAlwaysUpdate()).isEqualTo("child_updated_writeAlways"); // WRITE_ALWAYS: should
+ // change
+ assertThat(updated.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should
+ // NOT change
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void immutableSchema_simpleRecord_updateBehavior_isRespected() {
+ TableSchema schema = ImmutableTableSchema.create(SimpleImmutableRecord.class);
+
+ SimpleImmutableRecord item = SimpleImmutableRecord
+ .builder()
+ .id("1")
+ .writeAlwaysField("initial_writeAlways")
+ .writeOnceField("initial_writeOnce")
+ .childList(Arrays.asList(
+ UpdateBehaviorTestModels.SimpleImmutableChild.builder()
+ .id("child1")
+ .childAlwaysUpdate("child1_initial_writeAlways")
+ .childWriteOnce("child1_initial_writeOnce")
+ .build(),
+ UpdateBehaviorTestModels.SimpleImmutableChild.builder()
+ .id("child2")
+ .childAlwaysUpdate("child2_initial_writeAlways")
+ .childWriteOnce("child2_initial_writeOnce")
+ .build()))
+ .build();
+
+ DynamoDbTable table = createAndPut("simple-immutable-update-behavior", schema, item);
+
+ SimpleImmutableRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+ assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways");
+ assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce");
+ assertThat(result.getChildList()).hasSize(2);
+
+ // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields
+ // Note: We only update root-level attributes, not child list items, because
+ // list updates replace the entire list, overriding individual child update behaviors
+ SimpleImmutableRecord updated = SimpleImmutableRecord.builder()
+ .id("1")
+ .writeAlwaysField("updated_writeAlways")
+ .writeOnceField("updated_writeOnce")
+ .build();
+
+ table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ SimpleImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ // Root level assertions
+ assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change
+ assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change
+
+ // Child list should remain unchanged since we didn't update it
+ assertThat(afterUpdate.getChildList()).hasSize(2);
+ assertThat(afterUpdate.getChildList().get(0).getChildAlwaysUpdate()).isEqualTo("child1_initial_writeAlways");
+ assertThat(afterUpdate.getChildList().get(0).getChildWriteOnce()).isEqualTo("child1_initial_writeOnce");
+ assertThat(afterUpdate.getChildList().get(1).getChildAlwaysUpdate()).isEqualTo("child2_initial_writeAlways");
+ assertThat(afterUpdate.getChildList().get(1).getChildWriteOnce()).isEqualTo("child2_initial_writeOnce");
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void immutableSchema_nestedRecord_updateBehavior_isRespected() {
+ TableSchema schema = ImmutableTableSchema.create(NestedImmutableRecord.class);
+
+ NestedImmutableRecord item = NestedImmutableRecord
+ .builder()
+ .id("1")
+ .writeAlwaysField("initial_writeAlways")
+ .writeOnceField("initial_writeOnce")
+ .child(UpdateBehaviorTestModels.NestedImmutableChild
+ .builder()
+ .childAlwaysUpdate("child_initial_writeAlways")
+ .childWriteOnce("child_initial_writeOnce")
+ .build())
+ .build();
+
+ DynamoDbTable table = createAndPut("nested-immutable-update-behavior", schema, item);
+
+ NestedImmutableRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+ assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways");
+ assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce");
+ assertThat(result.getChild()).isNotNull();
+ assertThat(result.getChild().getChildAlwaysUpdate()).isEqualTo("child_initial_writeAlways");
+ assertThat(result.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce");
+
+ // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields
+ NestedImmutableRecord updated = NestedImmutableRecord.builder()
+ .id("1")
+ .writeAlwaysField("updated_writeAlways")
+ .writeOnceField("updated_writeOnce")
+ .build();
+
+ table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ NestedImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ // Root level assertions
+ assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change
+ assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void staticSchema_simpleRecord_updateBehavior_isRespected() {
+ SimpleStaticRecord item = new SimpleStaticRecord()
+ .setId("1")
+ .setWriteAlwaysField("initial_writeAlways")
+ .setWriteOnceField("initial_writeOnce");
+
+ DynamoDbTable table = createAndPut("simple-static-update-behavior",
+ buildStaticSchemaForSimpleRecord(), item);
+
+ SimpleStaticRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+ assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways");
+ assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce");
+
+ // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields
+ result.setWriteAlwaysField("updated_writeAlways");
+ result.setWriteOnceField("updated_writeOnce");
+
+ table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ SimpleStaticRecord updated = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ // Root level assertions
+ assertThat(updated.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change
+ assertThat(updated.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void staticSchema_nestedRecord_updateBehavior_isRespected() {
+ NestedStaticRecord item = new NestedStaticRecord()
+ .setId("1")
+ .setWriteAlwaysField("initial_writeAlways")
+ .setWriteOnceField("initial_writeOnce")
+ .setChild(new NestedStaticChildRecord()
+ .setChildAlwaysUpdate("child_initial_writeAlways")
+ .setChildWriteOnce("child_initial_writeOnce"));
+
+ DynamoDbTable table = createAndPut("nested-static-update-behavior",
+ buildStaticSchemaForNestedRecord(), item);
+
+ NestedStaticRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+ assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways");
+ assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce");
+ assertThat(result.getChild()).isNotNull();
+ assertThat(result.getChild().getChildAlwaysUpdate()).isEqualTo("child_initial_writeAlways");
+ assertThat(result.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce");
+
+ // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields
+ result.setWriteAlwaysField("updated_writeAlways");
+ result.setWriteOnceField("updated_writeOnce");
+ result.getChild().setChildAlwaysUpdate("child_updated_writeAlways");
+ result.getChild().setChildWriteOnce("child_updated_writeOnce");
+
+ table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ NestedStaticRecord updated = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ // Root level assertions
+ assertThat(updated.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change
+ assertThat(updated.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change
+
+ // Child level assertions
+ assertThat(updated.getChild().getChildAlwaysUpdate()).isEqualTo("child_updated_writeAlways"); // WRITE_ALWAYS: should
+ // change
+ assertThat(updated.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should
+ // NOT change
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void staticImmutableSchema_simpleRecord_updateBehavior_isRespected() {
+ SimpleImmutableRecord item = SimpleImmutableRecord
+ .builder()
+ .id("1")
+ .writeAlwaysField("initial_writeAlways")
+ .writeOnceField("initial_writeOnce")
+ .childList(Arrays.asList(
+ UpdateBehaviorTestModels.SimpleImmutableChild.builder()
+ .id("child1")
+ .childAlwaysUpdate("child1_initial_writeAlways")
+ .childWriteOnce("child1_initial_writeOnce")
+ .build(),
+ UpdateBehaviorTestModels.SimpleImmutableChild.builder()
+ .id("child2")
+ .childAlwaysUpdate("child2_initial_writeAlways")
+ .childWriteOnce("child2_initial_writeOnce")
+ .build()))
+ .build();
+
+ DynamoDbTable table = createAndPut("simple-static-immutable-update-behavior",
+ buildStaticImmutableSchemaForSimpleRecord(), item);
+
+ SimpleImmutableRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+ assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways");
+ assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce");
+ assertThat(result.getChildList()).hasSize(2);
+
+ // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields
+ // Note: We only update root-level attributes, not child list items, because
+ // list updates replace the entire list, overriding individual child update behaviors
+ SimpleImmutableRecord updated = SimpleImmutableRecord.builder()
+ .id("1")
+ .writeAlwaysField("updated_writeAlways")
+ .writeOnceField("updated_writeOnce")
+ .build();
+
+ table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ SimpleImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ // WRITE_ALWAYS fields should be updated
+ assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // should change
+
+ // WRITE_IF_NOT_EXISTS fields should NOT be updated (keep original values)
+ assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // should NOT change
+
+ // Child list should remain unchanged since we didn't update it
+ assertThat(afterUpdate.getChildList()).hasSize(2);
+ assertThat(afterUpdate.getChildList().get(0).getChildAlwaysUpdate()).isEqualTo("child1_initial_writeAlways");
+ assertThat(afterUpdate.getChildList().get(0).getChildWriteOnce()).isEqualTo("child1_initial_writeOnce");
+ assertThat(afterUpdate.getChildList().get(1).getChildAlwaysUpdate()).isEqualTo("child2_initial_writeAlways");
+ assertThat(afterUpdate.getChildList().get(1).getChildWriteOnce()).isEqualTo("child2_initial_writeOnce");
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void staticImmutableSchema_nestedRecord_updateBehavior_isRespected() {
+ NestedImmutableRecord item = NestedImmutableRecord.builder()
+ .id("1")
+ .writeAlwaysField("initial_writeAlways")
+ .writeOnceField("initial_writeOnce")
+ .child(UpdateBehaviorTestModels.NestedImmutableChild
+ .builder()
+ .childAlwaysUpdate("child_initial_writeAlways")
+ .childWriteOnce("child_initial_writeOnce")
+ .build())
+ .build();
+
+ DynamoDbTable table = createAndPut("nested-static-immutable-update-behavior",
+ buildStaticImmutableSchemaForNestedRecord(), item);
+
+ NestedImmutableRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+ assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways");
+ assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce");
+ assertThat(result.getChild()).isNotNull();
+ assertThat(result.getChild().getChildAlwaysUpdate()).isEqualTo("child_initial_writeAlways");
+ assertThat(result.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce");
+
+ // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields
+ NestedImmutableRecord updated = NestedImmutableRecord.builder()
+ .id("1")
+ .writeAlwaysField("updated_writeAlways")
+ .writeOnceField("updated_writeOnce")
+ .build();
+
+ table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ NestedImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1")));
+
+ // WRITE_ALWAYS fields should be updated
+ assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // should change
+
+ // WRITE_IF_NOT_EXISTS fields should NOT be updated (keep original values)
+ assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // should NOT change
+
+ table.deleteTable();
+ }
+
+ @Test
+ public void buildStaticSchema_forNestedRecordWithChildSchemaNotDefined_throwsIllegalStateException() {
+ NestedStaticRecord item = new NestedStaticRecord()
+ .setId("1")
+ .setWriteAlwaysField("initial_writeAlways")
+ .setWriteOnceField("initial_writeOnce")
+ .setChild(new NestedStaticChildRecord()
+ .setChildAlwaysUpdate("child_initial_writeAlways")
+ .setChildWriteOnce("child_initial_writeOnce"));
+
+ assertThatIllegalStateException()
+ .isThrownBy(() -> createAndPut("nested-static-update-behavior",
+ buildStaticSchemaForNestedRecord_NoChildSchemaDefined(),
+ item))
+
+ .withMessageContaining("Converter not found for EnhancedType(software.amazon.awssdk.enhanced.dynamodb"
+ + ".functionaltests.models.UpdateBehaviorTestModels$NestedStaticChildRecord)");
+ }
+
+ @Test
+ public void buildStaticImmutableSchema_forNestedRecordWithChildSchemaNotDefined_throwsIllegalStateException() {
+ NestedImmutableRecord item = NestedImmutableRecord.builder()
+ .id("1")
+ .writeAlwaysField("initial_writeAlways")
+ .writeOnceField("initial_writeOnce")
+ .child(UpdateBehaviorTestModels.NestedImmutableChild
+ .builder()
+ .childAlwaysUpdate("child_initial_writeAlways")
+ .childWriteOnce("child_initial_writeOnce")
+ .build())
+ .build();
+
+ assertThatIllegalStateException()
+ .isThrownBy(() -> createAndPut("nested-static-immutable-update-behavior",
+ buildStaticImmutableSchema_NoChildSchemaDefined(),
+ item))
+
+ .withMessageContaining("Converter not found for EnhancedType(software.amazon.awssdk.enhanced.dynamodb"
+ + ".functionaltests.models.UpdateBehaviorTestModels$NestedImmutableChild)");
+ }
+
+ private DynamoDbTable createAndPut(String tableSuffix, TableSchema schema, T item) {
+ currentTestTableName = BASE_TABLE_NAME + tableSuffix + System.nanoTime();
+ DynamoDbTable table = enhancedClient.table(currentTestTableName, schema);
+ table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
+ table.putItem(item);
+ return table;
+ }
+}
diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java
index 196d38282277..6726c2d94631 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,21 +2,31 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.BeanWithInvalidNestedAttributeName.ChildBeanWithInvalidAttributeName;
+import com.google.common.collect.ImmutableList;
import java.time.Instant;
import java.util.Collections;
+import java.util.List;
+import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.After;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
+import org.junit.rules.ExpectedException;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension;
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.CompositeRecord;
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FlattenRecord;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.BeanWithInvalidNestedAttributeName;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.BeanWithInvalidRootAttributeName;
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordWithUpdateBehavior;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordWithUpdateBehavior.TimestampListElement;
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithUpdateBehaviors;
import software.amazon.awssdk.enhanced.dynamodb.internal.client.ExtensionResolver;
import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode;
@@ -33,23 +43,38 @@ public class UpdateBehaviorTest extends LocalDynamoDbSyncTestBase {
private static final String TEST_ATTRIBUTE = "testAttribute";
private static final TableSchema TABLE_SCHEMA =
- TableSchema.fromClass(RecordWithUpdateBehaviors.class);
-
+ TableSchema.fromClass(RecordWithUpdateBehaviors.class);
+
private static final TableSchema TABLE_SCHEMA_FLATTEN_RECORD =
TableSchema.fromClass(FlattenRecord.class);
+ private static final TableSchema TABLE_SCHEMA_INVALID_ATTR_NAME_ON_ROOT =
+ TableSchema.fromClass(BeanWithInvalidRootAttributeName.class);
+
+ private static final TableSchema TABLE_SCHEMA_INVALID_ATTR_NAME_ON_NESTED_LEVEL =
+ TableSchema.fromClass(BeanWithInvalidNestedAttributeName.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);
+ private final DynamoDbTable beanWithInvalidRootAttrNameMappedTable =
+ enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA_INVALID_ATTR_NAME_ON_ROOT);
+
+ private final DynamoDbTable beanWithInvalidNestedAttrNameMappedTable =
+ enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA_INVALID_ATTR_NAME_ON_NESTED_LEVEL);
+
+ @Rule
+ public ExpectedException thrown = ExpectedException.none();
+
@Before
public void createTable() {
mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()));
@@ -62,11 +87,16 @@ public void deleteTable() {
@Test
public void updateBehaviors_firstUpdate() {
- Instant currentTime = Instant.now();
+ Instant currentTime = Instant.now().minusMillis(1);
+ NestedRecordWithUpdateBehavior nestedRecord = new NestedRecordWithUpdateBehavior();
+ nestedRecord.setId("id167");
+ nestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE);
+
RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors();
record.setId("id123");
record.setCreatedOn(INSTANT_1);
record.setLastUpdatedOn(INSTANT_2);
+ record.setNestedRecord(nestedRecord);
mappedTable.updateItem(record);
RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(record);
@@ -81,28 +111,57 @@ public void updateBehaviors_firstUpdate() {
assertThat(persistedRecord.getLastAutoUpdatedOnMillis().getEpochSecond()).isGreaterThanOrEqualTo(currentTime.getEpochSecond());
assertThat(persistedRecord.getCreatedAutoUpdateOn()).isAfterOrEqualTo(currentTime);
+
+ assertThat(persistedRecord.getNestedRecord().getId()).isEqualTo("id167");
+ assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isAfterOrEqualTo(currentTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfterOrEqualTo(currentTime);
+ assertThat(persistedRecord.getCreatedAutoUpdateOn()).isAfterOrEqualTo(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute());
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isEqualTo(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute());
}
@Test
public void updateBehaviors_secondUpdate() {
- Instant beforeUpdateInstant = Instant.now();
+ Instant beforeUpdateInstant = Instant.now().minusMillis(1);
+
+ NestedRecordWithUpdateBehavior secondNestedRecord = new NestedRecordWithUpdateBehavior();
+ secondNestedRecord.setId("id199");
+ secondNestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE);
+
+ NestedRecordWithUpdateBehavior nestedRecord = new NestedRecordWithUpdateBehavior();
+ nestedRecord.setId("id155");
+ nestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE);
+ nestedRecord.setNestedRecord(secondNestedRecord);
+
RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors();
record.setId("id123");
record.setCreatedOn(INSTANT_1);
record.setLastUpdatedOn(INSTANT_2);
+ record.setNestedRecord(nestedRecord);
mappedTable.updateItem(record);
RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(record);
assertThat(persistedRecord.getVersion()).isEqualTo(1L);
+
Instant firstUpdatedTime = persistedRecord.getLastAutoUpdatedOn();
Instant createdAutoUpdateOn = persistedRecord.getCreatedAutoUpdateOn();
+
assertThat(firstUpdatedTime).isAfterOrEqualTo(beforeUpdateInstant);
assertThat(persistedRecord.getFormattedLastAutoUpdatedOn().getEpochSecond())
.isGreaterThanOrEqualTo(beforeUpdateInstant.getEpochSecond());
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isNotNull();
+ assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute())
+ .isEqualTo(firstUpdatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute())
+ .isEqualTo(firstUpdatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedCreatedTimeAttribute())
+ .isEqualTo(firstUpdatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedUpdatedTimeAttribute())
+ .isEqualTo(firstUpdatedTime);
record.setVersion(1L);
record.setCreatedOn(INSTANT_2);
record.setLastUpdatedOn(INSTANT_2);
+ record.setNestedRecord(nestedRecord);
mappedTable.updateItem(record);
persistedRecord = mappedTable.getItem(record);
@@ -113,6 +172,14 @@ public void updateBehaviors_secondUpdate() {
Instant secondUpdatedTime = persistedRecord.getLastAutoUpdatedOn();
assertThat(secondUpdatedTime).isAfterOrEqualTo(firstUpdatedTime);
assertThat(persistedRecord.getCreatedAutoUpdateOn()).isEqualTo(createdAutoUpdateOn);
+ assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute())
+ .isEqualTo(secondUpdatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute())
+ .isEqualTo(secondUpdatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedCreatedTimeAttribute())
+ .isEqualTo(secondUpdatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedUpdatedTimeAttribute())
+ .isEqualTo(secondUpdatedTime);
}
@Test
@@ -164,7 +231,7 @@ public void updateBehaviors_transactWriteItems_secondUpdate() {
@Test
public void when_updatingNestedObjectWithSingleLevel_existingInformationIsPreserved_scalar_only_update() {
-
+ Instant currentTime = Instant.now().minusMillis(1);
NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L);
RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors();
@@ -173,26 +240,35 @@ public void when_updatingNestedObjectWithSingleLevel_existingInformationIsPreser
mappedTable.putItem(record);
+ RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
+
+ Instant nestedCreatedTime = persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute();
+ Instant nestedUpdatedTime = persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute();
+ assertThat(nestedCreatedTime).isAfter(currentTime);
+ assertThat(nestedUpdatedTime).isEqualTo(nestedCreatedTime);
+
NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior();
long updatedNestedCounter = 10L;
updatedNestedRecord.setNestedCounter(updatedNestedCounter);
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setNestedRecord(updatedNestedRecord);
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord);
- mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+ mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
- RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
+ persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter,
- TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1);
+ TEST_BEHAVIOUR_ATTRIBUTE, currentTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isEqualTo(nestedCreatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfter(nestedUpdatedTime);
}
@Test
public void when_updatingNestedObjectWithSingleLevel_default_mode_update_newMapCreated() {
-
+ Instant currentTime = Instant.now().minusMillis(1);
NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L);
RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors();
@@ -201,25 +277,35 @@ public void when_updatingNestedObjectWithSingleLevel_default_mode_update_newMapC
mappedTable.putItem(record);
+ RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
+
+ Instant nestedCreatedTime = persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute();
+ Instant nestedUpdatedTime = persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute();
+ assertThat(nestedCreatedTime).isNotNull();
+ assertThat(nestedUpdatedTime).isEqualTo(nestedCreatedTime);
+
NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior();
long updatedNestedCounter = 10L;
updatedNestedRecord.setNestedCounter(updatedNestedCounter);
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setNestedRecord(updatedNestedRecord);
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord);
- mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.DEFAULT));
+ mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.DEFAULT));
- RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
+ persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
- verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null);
+ verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null,
+ currentTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isAfter(nestedCreatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfter(nestedUpdatedTime);
}
@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();
@@ -232,16 +318,74 @@ public void when_updatingNestedObjectWithSingleLevel_with_no_mode_update_newMapC
long updatedNestedCounter = 10L;
updatedNestedRecord.setNestedCounter(updatedNestedCounter);
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setNestedRecord(updatedNestedRecord);
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord);
+
+ mappedTable.updateItem(r -> r.item(updateRecord));
+
+ RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
+
+ verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null,
+ currentTime);
+ }
+
+ @Test
+ public void when_updatingNestedObjectList_no_matter_mode_update_newListCreated_with_timestampGenerated() {
+ Instant currentTime = Instant.now().minusMillis(1);
+ NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L);
+ nestedRecord.setNestedUpdatedTimeAttribute(null);
+ TimestampListElement firstElement = new TimestampListElement();
+ firstElement.setId("id1");
+ firstElement.setAttribute("attr1");
+ TimestampListElement secondElement = new TimestampListElement();
+ secondElement.setId("id2");
+ secondElement.setAttribute("attr2");
+ nestedRecord.setNestedRecordList(ImmutableList.of(firstElement, secondElement));
+
+ RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors();
+ record.setId("id123");
+ record.setNestedRecord(nestedRecord);
+ record.setNestedRecordList(ImmutableList.of(firstElement, secondElement));
- mappedTable.updateItem(r -> r.item(update_record));
+ mappedTable.putItem(record);
RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
- verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null);
+ List nestedRecordList = persistedRecord.getNestedRecord().getNestedRecordList();
+ Instant firstOperationTime = nestedRecordList.get(0).getTimeAttributeElement();
+
+ assertThat(persistedRecord.getNestedRecordList().get(0).getTimeAttributeElement()).isAfter(currentTime);
+ assertThat(persistedRecord.getNestedRecordList().get(1).getTimeAttributeElement()).isAfter(currentTime);
+ assertThat(nestedRecordList.get(0).getTimeAttributeElement()).isAfter(currentTime);
+ assertThat(nestedRecordList.get(1).getTimeAttributeElement()).isEqualTo(firstOperationTime);
+
+ NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior();
+ long updatedNestedCounter = 10L;
+ updatedNestedRecord.setNestedUpdatedTimeAttribute(null);
+ firstElement.setAttribute("attr44");
+ secondElement.setAttribute("attr55");
+ updatedNestedRecord.setNestedCounter(updatedNestedCounter);
+ updatedNestedRecord.setNestedRecordList(ImmutableList.of(firstElement, secondElement));
+
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord);
+ updateRecord.setNestedRecordList(ImmutableList.of(firstElement));
+
+ mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
+
+ nestedRecordList = persistedRecord.getNestedRecord().getNestedRecordList();
+
+ assertThat(persistedRecord.getNestedRecordList()).hasSize(1);
+ assertThat(persistedRecord.getNestedRecordList().get(0).getTimeAttributeElement()).isAfter(firstOperationTime);
+ assertThat(nestedRecordList).hasSize(2);
+ assertThat(nestedRecordList.get(0).getTimeAttributeElement()).isAfter(firstOperationTime);
+ assertThat(nestedRecordList.get(1).getTimeAttributeElement()).isAfter(firstOperationTime);
}
@Test
@@ -258,15 +402,59 @@ public void when_updatingNestedObjectToEmptyWithSingleLevel_existingInformationI
NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior();
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setNestedRecord(updatedNestedRecord);
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord);
- mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+ mappedTable.updateItem(r -> r.item(updateRecord).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.getNestedRecord().getId()).isNull();
+ assertThat(persistedRecord.getNestedRecord().getNestedCounter()).isNull();
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isNull();
+ assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isNotNull();
+ }
+
+ @Test
+ public void when_updatingNestedObjectWithSingleLevel_updateBehaviorIsChecked_scalar_only_update() {
+ Instant currentTime = Instant.now().minusMillis(1);
+ NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L);
+
+ RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors();
+ record.setId("id123");
+ record.setNestedRecord(nestedRecord);
+
+ mappedTable.putItem(record);
+
+ RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
+
+ Instant nestedCreatedTime = persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute();
+ Instant nestedUpdatedTime = persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute();
+ assertThat(nestedCreatedTime).isAfter(currentTime);
+ assertThat(nestedUpdatedTime).isEqualTo(nestedCreatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo(TEST_BEHAVIOUR_ATTRIBUTE);
+
+ NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior();
+ long updatedNestedCounter = 10L;
+ updatedNestedRecord.setNestedCounter(updatedNestedCounter);
+ updatedNestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE + "updated");
+
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord);
+
+ mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+
+ persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
+
+ //WRITE_IF_NOT_EXISTS detected on createdTimeAttribute and updateBehaviorAttribute -> not changed
+ assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isEqualTo(nestedCreatedTime);
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo(TEST_BEHAVIOUR_ATTRIBUTE);
+
+ assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfter(nestedUpdatedTime);
}
private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long counter) {
@@ -274,7 +462,6 @@ private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long
nestedRecordWithDefaults.setId(id);
nestedRecordWithDefaults.setNestedCounter(counter);
nestedRecordWithDefaults.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE);
- nestedRecordWithDefaults.setNestedTimeAttribute(INSTANT_1);
return nestedRecordWithDefaults;
}
@@ -282,31 +469,34 @@ private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long
private void verifyMultipleLevelNestingTargetedUpdateBehavior(NestedRecordWithUpdateBehavior nestedRecord,
long updatedOuterNestedCounter,
long updatedInnerNestedCounter,
- String test_behav_attribute,
- Instant expected_time) {
+ String testBehaviorAttribute,
+ Instant expectedTime) {
assertThat(nestedRecord).isNotNull();
assertThat(nestedRecord.getNestedRecord()).isNotNull();
assertThat(nestedRecord.getNestedCounter()).isEqualTo(updatedOuterNestedCounter);
+ assertThat(nestedRecord.getNestedCreatedTimeAttribute()).isAfter(expectedTime);
+ assertThat(nestedRecord.getNestedUpdatedTimeAttribute()).isAfter(expectedTime);
assertThat(nestedRecord.getNestedRecord()).isNotNull();
assertThat(nestedRecord.getNestedRecord().getNestedCounter()).isEqualTo(updatedInnerNestedCounter);
assertThat(nestedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo(
- test_behav_attribute);
- assertThat(nestedRecord.getNestedRecord().getNestedTimeAttribute()).isEqualTo(expected_time);
+ testBehaviorAttribute);
+ assertThat(nestedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isAfter(expectedTime);
+ assertThat(nestedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfter(expectedTime);
}
private void verifySingleLevelNestingTargetedUpdateBehavior(NestedRecordWithUpdateBehavior nestedRecord,
- long updatedNestedCounter, String expected_behav_attr,
- Instant expected_time) {
+ long updatedNestedCounter, String expectedBehaviorAttr,
+ Instant expectedTime) {
assertThat(nestedRecord).isNotNull();
assertThat(nestedRecord.getNestedCounter()).isEqualTo(updatedNestedCounter);
- assertThat(nestedRecord.getNestedUpdateBehaviorAttribute()).isEqualTo(expected_behav_attr);
- assertThat(nestedRecord.getNestedTimeAttribute()).isEqualTo(expected_time);
+ assertThat(nestedRecord.getNestedUpdateBehaviorAttribute()).isEqualTo(expectedBehaviorAttr);
+ assertThat(nestedRecord.getNestedCreatedTimeAttribute()).isAfter(expectedTime);
+ assertThat(nestedRecord.getNestedUpdatedTimeAttribute()).isAfter(expectedTime);
}
@Test
public void when_updatingNestedObjectWithMultipleLevels_inScalarOnlyMode_existingInformationIsPreserved() {
-
NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L);
NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L);
@@ -327,12 +517,12 @@ public void when_updatingNestedObjectWithMultipleLevels_inScalarOnlyMode_existin
long outerNestedCounter = 200L;
updatedNestedRecord1.setNestedCounter(outerNestedCounter);
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setNestedRecord(updatedNestedRecord1);
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord1);
- mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+ mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
@@ -342,7 +532,6 @@ public void when_updatingNestedObjectWithMultipleLevels_inScalarOnlyMode_existin
@Test
public void when_updatingNestedObjectWithMultipleLevels_inMapsOnlyMode_existingInformationIsPreserved() {
-
NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L);
NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L);
@@ -358,12 +547,12 @@ public void when_updatingNestedObjectWithMultipleLevels_inMapsOnlyMode_existingI
long outerNestedCounter = 200L;
updatedNestedRecord1.setNestedCounter(outerNestedCounter);
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setNestedRecord(updatedNestedRecord1);
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord1);
- mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY));
+ mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY));
RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
@@ -373,7 +562,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);
@@ -394,22 +583,22 @@ public void when_updatingNestedObjectWithMultipleLevels_default_mode_existingInf
long outerNestedCounter = 200L;
updatedNestedRecord1.setNestedCounter(outerNestedCounter);
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setNestedRecord(updatedNestedRecord1);
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setNestedRecord(updatedNestedRecord1);
- mappedTable.updateItem(r -> r.item(update_record));
+ mappedTable.updateItem(r -> r.item(updateRecord));
RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123")));
- verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, innerNestedCounter, null,
- null);
+ verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter,
+ innerNestedCounter, null,
+ currentTime);
}
@Test
public void when_updatingNestedNonScalarObject_scalar_only_update_throwsDynamoDBException() {
-
NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L);
nestedRecord.setAttribute(TEST_ATTRIBUTE);
@@ -418,35 +607,34 @@ public void when_updatingNestedNonScalarObject_scalar_only_update_throwsDynamoDB
mappedTable.putItem(record);
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setKey("abc");
- update_record.setNestedRecord(nestedRecord);
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setKey("abc");
+ updateRecord.setNestedRecord(nestedRecord);
- assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)))
+ assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)))
.isInstanceOf(DynamoDbException.class);
}
@Test
public void when_updatingNestedMap_mapsOnlyMode_newMapIsCreatedAndStored() {
-
RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors();
record.setId("id123");
mappedTable.putItem(record);
- RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors();
- update_record.setId("id123");
- update_record.setVersion(1L);
- update_record.setKey("abc");
+ RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors();
+ updateRecord.setId("id123");
+ updateRecord.setVersion(1L);
+ updateRecord.setKey("abc");
NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L);
nestedRecord.setAttribute(TEST_ATTRIBUTE);
- update_record.setNestedRecord(nestedRecord);
+ updateRecord.setNestedRecord(nestedRecord);
RecordWithUpdateBehaviors persistedRecord =
- mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY));
+ mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY));
verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), 5L, TEST_BEHAVIOUR_ATTRIBUTE,
INSTANT_1);
@@ -470,21 +658,20 @@ 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("nestedCreatedTimeAttribute")).isNotNull();
+ assertThat(nestedRecord.get("nestedUpdatedTimeAttribute")).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());
}
@Test
public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformationIsPreserved_scalar_only_update() {
-
NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id123", 10L);
CompositeRecord compositeRecord = new CompositeRecord();
@@ -493,15 +680,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);
@@ -514,11 +701,8 @@ public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformatio
TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1);
}
-
-
@Test
public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformationIsPreserved_scalar_only_update() {
-
NestedRecordWithUpdateBehavior outerNestedRecord = createNestedWithDefaults("id123", 10L);
NestedRecordWithUpdateBehavior innerNestedRecord = createNestedWithDefaults("id456", 5L);
outerNestedRecord.setNestedRecord(innerNestedRecord);
@@ -529,36 +713,37 @@ 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);
assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedCounter()).isEqualTo(100L);
assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedRecord().getNestedCounter()).isEqualTo(50L);
+ assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedRecord().getNestedCreatedTimeAttribute()).isNotNull();
}
/**
- * Currently, nested records are not updated through extensions.
+ * Currently, nested records are not updated through extensions (only the timestamp).
*/
@Test
public void updateBehaviors_nested() {
@@ -579,6 +764,40 @@ 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().getNestedCreatedTimeAttribute()).isNotNull();
+ }
+
+ @Test
+ public void updateBehaviour_onItemWithRootAttributeNameContainingReservedMarker_throwsException() {
+
+ thrown.expect(IllegalArgumentException.class);
+ thrown.expectMessage("Attribute name 'attr_NESTED_ATTR_UPDATE_' contains reserved marker "
+ + "'_NESTED_ATTR_UPDATE_' and is not allowed.");
+
+ BeanWithInvalidRootAttributeName record = new BeanWithInvalidRootAttributeName();
+ record.setId("1");
+ record.setAttr_NESTED_ATTR_UPDATE_(INSTANT_1);
+
+ beanWithInvalidRootAttrNameMappedTable.updateItem(r -> r.item(record)
+ .ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
+ }
+
+ @Test
+ public void updateBehaviour_onItemWithNestedAttributeNameContainingReservedMarker_throwsException() {
+
+ thrown.expect(IllegalArgumentException.class);
+ thrown.expectMessage("Attribute name 'childAttr_NESTED_ATTR_UPDATE_' contains reserved marker "
+ + "'_NESTED_ATTR_UPDATE_' and is not allowed.");
+
+ BeanWithInvalidNestedAttributeName record = new BeanWithInvalidNestedAttributeName();
+ record.setId("1");
+
+ ChildBeanWithInvalidAttributeName childBean = new ChildBeanWithInvalidAttributeName();
+ childBean.setId("2");
+ childBean.setChildAttr_NESTED_ATTR_UPDATE_(INSTANT_1);
+ record.setChild(childBean);
+
+ beanWithInvalidNestedAttrNameMappedTable.updateItem(r -> r.item(record)
+ .ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY));
}
}
diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java
index 883a89813c1a..17aca019b7a1 100644
--- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java
+++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java
@@ -18,6 +18,7 @@
import static software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior.WRITE_IF_NOT_EXISTS;
import java.time.Instant;
+import java.util.List;
import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAtomicCounter;
import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute;
import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute;
@@ -30,10 +31,12 @@ public class NestedRecordWithUpdateBehavior {
private String id;
private String nestedUpdateBehaviorAttribute;
private Long nestedVersionedAttribute;
- private Instant nestedTimeAttribute;
+ private Instant nestedCreatedTimeAttribute;
+ private Instant nestedUpdatedTimeAttribute;
private Long nestedCounter;
private NestedRecordWithUpdateBehavior nestedRecord;
private String attribute;
+ private List nestedRecordList;
@DynamoDbPartitionKey
public String getId() {
@@ -63,12 +66,22 @@ public void setNestedVersionedAttribute(Long nestedVersionedAttribute) {
}
@DynamoDbAutoGeneratedTimestampAttribute
- public Instant getNestedTimeAttribute() {
- return nestedTimeAttribute;
+ @DynamoDbUpdateBehavior(WRITE_IF_NOT_EXISTS)
+ public Instant getNestedCreatedTimeAttribute() {
+ return nestedCreatedTimeAttribute;
+ }
+
+ public void setNestedCreatedTimeAttribute(Instant nestedCreatedTimeAttribute) {
+ this.nestedCreatedTimeAttribute = nestedCreatedTimeAttribute;
+ }
+
+ @DynamoDbAutoGeneratedTimestampAttribute
+ public Instant getNestedUpdatedTimeAttribute() {
+ return nestedUpdatedTimeAttribute;
}
- public void setNestedTimeAttribute(Instant nestedTimeAttribute) {
- this.nestedTimeAttribute = nestedTimeAttribute;
+ public void setNestedUpdatedTimeAttribute(Instant nestedUpdatedTimeAttribute) {
+ this.nestedUpdatedTimeAttribute = nestedUpdatedTimeAttribute;
}
@DynamoDbAtomicCounter
@@ -95,4 +108,43 @@ public String getAttribute() {
public void setAttribute(String attribute) {
this.attribute = attribute;
}
+
+ public List getNestedRecordList() { return nestedRecordList;}
+
+ public void setNestedRecordList(List nestedRecordList) {
+ this.nestedRecordList = nestedRecordList;
+ }
+
+ @DynamoDbBean
+ public static class TimestampListElement {
+ private String id;
+ private String attribute;
+ private Instant timeAttributeElement;
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getAttribute() {
+ return attribute;
+ }
+
+ public void setAttribute(String attribute) {
+ this.attribute = attribute;
+ }
+
+ @DynamoDbAutoGeneratedTimestampAttribute
+ public Instant getTimeAttributeElement() {
+ return timeAttributeElement;
+ }
+
+ public void setTimeAttributeElement(Instant timeAttributeElement) {
+ this.timeAttributeElement = timeAttributeElement;
+ }
+ }
}
diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java
index 8bd874fee002..c6c248f3d2e4 100644
--- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java
+++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java
@@ -15,19 +15,21 @@
package software.amazon.awssdk.enhanced.dynamodb.functionaltests.models;
+import static software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior.WRITE_IF_NOT_EXISTS;
+
import java.time.Instant;
+import java.util.List;
import software.amazon.awssdk.enhanced.dynamodb.converters.EpochMillisFormatTestConverter;
import software.amazon.awssdk.enhanced.dynamodb.converters.TimeFormatUpdateTestConverter;
import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute;
import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute;
+import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordWithUpdateBehavior.TimestampListElement;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior;
-import static software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior.WRITE_IF_NOT_EXISTS;
-
@DynamoDbBean
public class RecordWithUpdateBehaviors {
private String id;
@@ -40,6 +42,7 @@ public class RecordWithUpdateBehaviors {
private Instant formattedLastAutoUpdatedOn;
private NestedRecordWithUpdateBehavior nestedRecord;
private String key;
+ private List nestedRecordList;
@DynamoDbPartitionKey
public String getId() {
@@ -133,4 +136,10 @@ public NestedRecordWithUpdateBehavior getNestedRecord() {
public void setNestedRecord(NestedRecordWithUpdateBehavior nestedRecord) {
this.nestedRecord = nestedRecord;
}
+
+ public List getNestedRecordList() { return nestedRecordList;}
+
+ public void setNestedRecordList(List nestedRecordList) {
+ this.nestedRecordList = nestedRecordList;
+ }
}
diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java
new file mode 100644
index 000000000000..a573b1897f0e
--- /dev/null
+++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java
@@ -0,0 +1,901 @@
+/*
+ * 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.mapper.StaticAttributeTags.primaryPartitionKey;
+import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.updateBehavior;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
+import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior;
+
+/**
+ * Test models for update behavior functionality testing across 4 schema types: Bean, Static, Immutable, and StaticImmutable.
+ * Provides simple/nested model variants, each containing writeAlwaysField (WRITE_ALWAYS) and writeOnceField (WRITE_IF_NOT_EXISTS)
+ * attributes at root and nested levels. Used by NestedUpdateBehaviorTest to validate @DynamoDbUpdateBehavior annotations.
+ */
+public final class UpdateBehaviorTestModels {
+
+ private UpdateBehaviorTestModels() {
+ }
+
+ @DynamoDbBean
+ public static class SimpleBean {
+ private String id;
+ private String writeAlwaysField;
+ private String writeOnceField;
+ private List childList;
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ public SimpleBean setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
+ public String getWriteAlwaysField() {
+ return writeAlwaysField;
+ }
+
+ public SimpleBean setWriteAlwaysField(String writeAlwaysField) {
+ this.writeAlwaysField = writeAlwaysField;
+ return this;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public String getWriteOnceField() {
+ return writeOnceField;
+ }
+
+ public SimpleBean setWriteOnceField(String writeOnceField) {
+ this.writeOnceField = writeOnceField;
+ return this;
+ }
+
+ public List getChildList() {
+ return childList == null ? null : Collections.unmodifiableList(childList);
+ }
+
+ public SimpleBean setChildList(List childList) {
+ this.childList = childList == null ? null : Collections.unmodifiableList(childList);
+ return this;
+ }
+ }
+
+ @DynamoDbBean
+ public static class NestedBean {
+ private String id;
+ private String writeAlwaysField;
+ private String writeOnceField;
+ private NestedBeanChild child;
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ public NestedBean setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
+ public String getWriteAlwaysField() {
+ return writeAlwaysField;
+ }
+
+ public NestedBean setWriteAlwaysField(String writeAlwaysField) {
+ this.writeAlwaysField = writeAlwaysField;
+ return this;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public String getWriteOnceField() {
+ return writeOnceField;
+ }
+
+ public NestedBean setWriteOnceField(String writeOnceField) {
+ this.writeOnceField = writeOnceField;
+ return this;
+ }
+
+ public NestedBeanChild getChild() {
+ return child;
+ }
+
+ public NestedBean setChild(NestedBeanChild child) {
+ this.child = child;
+ return this;
+ }
+ }
+
+ @DynamoDbBean
+ public static class SimpleBeanChild {
+ private String id;
+ private String childAlwaysUpdate;
+ private String childWriteOnce;
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ public SimpleBeanChild setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
+ public String getChildAlwaysUpdate() {
+ return childAlwaysUpdate;
+ }
+
+ public SimpleBeanChild setChildAlwaysUpdate(String childAlwaysUpdate) {
+ this.childAlwaysUpdate = childAlwaysUpdate;
+ return this;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public String getChildWriteOnce() {
+ return childWriteOnce;
+ }
+
+ public SimpleBeanChild setChildWriteOnce(String childWriteOnce) {
+ this.childWriteOnce = childWriteOnce;
+ return this;
+ }
+ }
+
+ @DynamoDbBean
+ public static class NestedBeanChild {
+ private String childAlwaysUpdate;
+ private String childWriteOnce;
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
+ public String getChildAlwaysUpdate() {
+ return childAlwaysUpdate;
+ }
+
+ public NestedBeanChild setChildAlwaysUpdate(String childAlwaysUpdate) {
+ this.childAlwaysUpdate = childAlwaysUpdate;
+ return this;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public String getChildWriteOnce() {
+ return childWriteOnce;
+ }
+
+ public NestedBeanChild setChildWriteOnce(String childWriteOnce) {
+ this.childWriteOnce = childWriteOnce;
+ return this;
+ }
+ }
+
+ @DynamoDbImmutable(builder = SimpleImmutableRecord.Builder.class)
+ public static final class SimpleImmutableRecord {
+ private final String id;
+ private final String writeAlwaysField;
+ private final String writeOnceField;
+ private final List childList;
+
+ private SimpleImmutableRecord(Builder b) {
+ this.id = b.id;
+ this.writeAlwaysField = b.writeAlwaysField;
+ this.writeOnceField = b.writeOnceField;
+ this.childList = b.childList;
+ }
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
+ public String getWriteAlwaysField() {
+ return writeAlwaysField;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public String getWriteOnceField() {
+ return writeOnceField;
+ }
+
+ 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 String writeAlwaysField;
+ private String writeOnceField;
+ private List childList;
+
+ public Builder id(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder writeAlwaysField(String writeAlwaysField) {
+ this.writeAlwaysField = writeAlwaysField;
+ return this;
+ }
+
+ public Builder writeOnceField(String writeOnceField) {
+ this.writeOnceField = writeOnceField;
+ return this;
+ }
+
+ public Builder childList(List childList) {
+ this.childList = childList == null ? null : Collections.unmodifiableList(childList);
+ return this;
+ }
+
+ public SimpleImmutableRecord build() {
+ return new SimpleImmutableRecord(this);
+ }
+ }
+ }
+
+ @DynamoDbImmutable(builder = NestedImmutableRecord.Builder.class)
+ public static final class NestedImmutableRecord {
+ private final String id;
+ private final String writeAlwaysField;
+ private final String writeOnceField;
+ private final NestedImmutableChild child;
+
+ private NestedImmutableRecord(Builder b) {
+ this.id = b.id;
+ this.writeAlwaysField = b.writeAlwaysField;
+ this.writeOnceField = b.writeOnceField;
+ this.child = b.child;
+ }
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
+ public String getWriteAlwaysField() {
+ return writeAlwaysField;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public String getWriteOnceField() {
+ return writeOnceField;
+ }
+
+ public NestedImmutableChild getChild() {
+ return child;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String id;
+ private String writeAlwaysField;
+ private String writeOnceField;
+ private NestedImmutableChild child;
+
+ public Builder id(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder writeAlwaysField(String writeAlwaysField) {
+ this.writeAlwaysField = writeAlwaysField;
+ return this;
+ }
+
+ public Builder writeOnceField(String writeOnceField) {
+ this.writeOnceField = writeOnceField;
+ return this;
+ }
+
+ public Builder child(NestedImmutableChild child) {
+ this.child = child;
+ return this;
+ }
+
+ public NestedImmutableRecord build() {
+ return new NestedImmutableRecord(this);
+ }
+ }
+ }
+
+ @DynamoDbImmutable(builder = SimpleImmutableChild.Builder.class)
+ public static final class SimpleImmutableChild {
+ private final String id;
+ private final String childAlwaysUpdate;
+ private final String childWriteOnce;
+
+ private SimpleImmutableChild(Builder b) {
+ this.id = b.id;
+ this.childAlwaysUpdate = b.childAlwaysUpdate;
+ this.childWriteOnce = b.childWriteOnce;
+ }
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
+ public String getChildAlwaysUpdate() {
+ return childAlwaysUpdate;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public String getChildWriteOnce() {
+ return childWriteOnce;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String id;
+ private String childAlwaysUpdate;
+ private String childWriteOnce;
+
+ public Builder id(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder childAlwaysUpdate(String childAlwaysUpdate) {
+ this.childAlwaysUpdate = childAlwaysUpdate;
+ return this;
+ }
+
+ public Builder childWriteOnce(String childWriteOnce) {
+ this.childWriteOnce = childWriteOnce;
+ return this;
+ }
+
+ public SimpleImmutableChild build() {
+ return new SimpleImmutableChild(this);
+ }
+ }
+ }
+
+ @DynamoDbImmutable(builder = NestedImmutableChild.Builder.class)
+ public static final class NestedImmutableChild {
+ private final String childAlwaysUpdate;
+ private final String childWriteOnce;
+
+ private NestedImmutableChild(Builder b) {
+ this.childAlwaysUpdate = b.childAlwaysUpdate;
+ this.childWriteOnce = b.childWriteOnce;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
+ public String getChildAlwaysUpdate() {
+ return childAlwaysUpdate;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public String getChildWriteOnce() {
+ return childWriteOnce;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String childAlwaysUpdate;
+ private String childWriteOnce;
+
+ public Builder childAlwaysUpdate(String childAlwaysUpdate) {
+ this.childAlwaysUpdate = childAlwaysUpdate;
+ return this;
+ }
+
+ public Builder childWriteOnce(String childWriteOnce) {
+ this.childWriteOnce = childWriteOnce;
+ return this;
+ }
+
+ public NestedImmutableChild build() {
+ return new NestedImmutableChild(this);
+ }
+ }
+ }
+
+ public static class SimpleStaticRecord {
+ private String id;
+ private String writeAlwaysField;
+ private String writeOnceField;
+
+ public String getId() {
+ return id;
+ }
+
+ public SimpleStaticRecord setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public String getWriteAlwaysField() {
+ return writeAlwaysField;
+ }
+
+ public SimpleStaticRecord setWriteAlwaysField(String writeAlwaysField) {
+ this.writeAlwaysField = writeAlwaysField;
+ return this;
+ }
+
+ public String getWriteOnceField() {
+ return writeOnceField;
+ }
+
+ public SimpleStaticRecord setWriteOnceField(String writeOnceField) {
+ this.writeOnceField = writeOnceField;
+ return this;
+ }
+ }
+
+ public static class NestedStaticRecord {
+ private String id;
+ private String writeAlwaysField;
+ private String writeOnceField;
+ private NestedStaticChildRecord child;
+
+ public String getId() {
+ return id;
+ }
+
+ public NestedStaticRecord setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public String getWriteAlwaysField() {
+ return writeAlwaysField;
+ }
+
+ public NestedStaticRecord setWriteAlwaysField(String writeAlwaysField) {
+ this.writeAlwaysField = writeAlwaysField;
+ return this;
+ }
+
+ public String getWriteOnceField() {
+ return writeOnceField;
+ }
+
+ public NestedStaticRecord setWriteOnceField(String writeOnceField) {
+ this.writeOnceField = writeOnceField;
+ return this;
+ }
+
+ public NestedStaticChildRecord getChild() {
+ return child;
+ }
+
+ public NestedStaticRecord setChild(NestedStaticChildRecord child) {
+ this.child = child;
+ return this;
+ }
+ }
+
+ public static class NestedStaticChildRecord {
+ private String childAlwaysUpdate;
+ private String childWriteOnce;
+
+ public String getChildAlwaysUpdate() {
+ return childAlwaysUpdate;
+ }
+
+ public NestedStaticChildRecord setChildAlwaysUpdate(String childAlwaysUpdate) {
+ this.childAlwaysUpdate = childAlwaysUpdate;
+ return this;
+ }
+
+ public String getChildWriteOnce() {
+ return childWriteOnce;
+ }
+
+ public NestedStaticChildRecord setChildWriteOnce(String childWriteOnce) {
+ this.childWriteOnce = childWriteOnce;
+ return this;
+ }
+ }
+
+ public static TableSchema buildStaticSchemaForSimpleRecord() {
+ return StaticTableSchema
+ .builder(SimpleStaticRecord.class)
+ .newItemSupplier(SimpleStaticRecord::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(SimpleStaticRecord::getId)
+ .setter(SimpleStaticRecord::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(String.class, a -> a.name("writeAlwaysField")
+ .getter(SimpleStaticRecord::getWriteAlwaysField)
+ .setter(SimpleStaticRecord::setWriteAlwaysField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name("writeOnceField")
+ .getter(SimpleStaticRecord::getWriteOnceField)
+ .setter(SimpleStaticRecord::setWriteOnceField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .build();
+ }
+
+ public static TableSchema buildStaticSchemaForNestedRecord() {
+ TableSchema childSchema = StaticTableSchema
+ .builder(NestedStaticChildRecord.class)
+ .newItemSupplier(NestedStaticChildRecord::new)
+ .addAttribute(String.class, a -> a.name("childAlwaysUpdate")
+ .getter(NestedStaticChildRecord::getChildAlwaysUpdate)
+ .setter(NestedStaticChildRecord::setChildAlwaysUpdate)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name("childWriteOnce")
+ .getter(NestedStaticChildRecord::getChildWriteOnce)
+ .setter(NestedStaticChildRecord::setChildWriteOnce)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .build();
+
+ return StaticTableSchema
+ .builder(NestedStaticRecord.class)
+ .newItemSupplier(NestedStaticRecord::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(NestedStaticRecord::getId)
+ .setter(NestedStaticRecord::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(String.class, a -> a.name("writeAlwaysField")
+ .getter(NestedStaticRecord::getWriteAlwaysField)
+ .setter(NestedStaticRecord::setWriteAlwaysField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name("writeOnceField")
+ .getter(NestedStaticRecord::getWriteOnceField)
+ .setter(NestedStaticRecord::setWriteOnceField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .addAttribute(EnhancedType.documentOf(NestedStaticChildRecord.class, childSchema),
+ a -> a.name("child")
+ .getter(NestedStaticRecord::getChild)
+ .setter(NestedStaticRecord::setChild))
+ .build();
+ }
+
+ public static TableSchema buildStaticSchemaForNestedRecord_NoChildSchemaDefined() {
+ TableSchema childSchema = null;
+
+ return StaticTableSchema
+ .builder(NestedStaticRecord.class)
+ .newItemSupplier(NestedStaticRecord::new)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(NestedStaticRecord::getId)
+ .setter(NestedStaticRecord::setId)
+ .tags(primaryPartitionKey()))
+ .addAttribute(String.class, a -> a.name("writeAlwaysField")
+ .getter(NestedStaticRecord::getWriteAlwaysField)
+ .setter(NestedStaticRecord::setWriteAlwaysField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name("writeOnceField")
+ .getter(NestedStaticRecord::getWriteOnceField)
+ .setter(NestedStaticRecord::setWriteOnceField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .addAttribute(EnhancedType.documentOf(NestedStaticChildRecord.class, childSchema),
+ a -> a.name("child")
+ .getter(NestedStaticRecord::getChild)
+ .setter(NestedStaticRecord::setChild))
+ .build();
+ }
+
+ public static TableSchema buildStaticImmutableSchemaForSimpleRecord() {
+ 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(String.class, a -> a.name(
+ "childAlwaysUpdate")
+ .getter(SimpleImmutableChild::getChildAlwaysUpdate)
+ .setter(SimpleImmutableChild.Builder::childAlwaysUpdate)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name(
+ "childWriteOnce")
+ .getter(SimpleImmutableChild::getChildWriteOnce)
+ .setter(SimpleImmutableChild.Builder::childWriteOnce)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .build();
+
+ return StaticImmutableTableSchema
+ .builder(SimpleImmutableRecord.class, SimpleImmutableRecord.Builder.class)
+ .newItemBuilder(SimpleImmutableRecord::builder, SimpleImmutableRecord.Builder::build)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(SimpleImmutableRecord::getId)
+ .setter(SimpleImmutableRecord.Builder::id)
+ .tags(primaryPartitionKey()))
+ .addAttribute(String.class, a -> a.name("writeAlwaysField")
+ .getter(SimpleImmutableRecord::getWriteAlwaysField)
+ .setter(SimpleImmutableRecord.Builder::writeAlwaysField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name("writeOnceField")
+ .getter(SimpleImmutableRecord::getWriteOnceField)
+ .setter(SimpleImmutableRecord.Builder::writeOnceField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .addAttribute(EnhancedType.listOf(EnhancedType.documentOf(SimpleImmutableChild.class,
+ childSchema)),
+ a -> a.name("childList")
+ .getter(SimpleImmutableRecord::getChildList)
+ .setter(SimpleImmutableRecord.Builder::childList))
+ .build();
+ }
+
+ public static TableSchema buildStaticImmutableSchemaForNestedRecord() {
+ TableSchema childSchema = StaticImmutableTableSchema
+ .builder(NestedImmutableChild.class, NestedImmutableChild.Builder.class)
+ .newItemBuilder(NestedImmutableChild::builder, NestedImmutableChild.Builder::build)
+ .addAttribute(String.class, a -> a.name("childAlwaysUpdate")
+ .getter(NestedImmutableChild::getChildAlwaysUpdate)
+ .setter(NestedImmutableChild.Builder::childAlwaysUpdate)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name("childWriteOnce")
+ .getter(NestedImmutableChild::getChildWriteOnce)
+ .setter(NestedImmutableChild.Builder::childWriteOnce)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .build();
+
+ return StaticImmutableTableSchema
+ .builder(NestedImmutableRecord.class, NestedImmutableRecord.Builder.class)
+ .newItemBuilder(NestedImmutableRecord::builder, NestedImmutableRecord.Builder::build)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(NestedImmutableRecord::getId)
+ .setter(NestedImmutableRecord.Builder::id)
+ .tags(primaryPartitionKey()))
+ .addAttribute(String.class, a -> a.name("writeAlwaysField")
+ .getter(NestedImmutableRecord::getWriteAlwaysField)
+ .setter(NestedImmutableRecord.Builder::writeAlwaysField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name("writeOnceField")
+ .getter(NestedImmutableRecord::getWriteOnceField)
+ .setter(NestedImmutableRecord.Builder::writeOnceField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .addAttribute(EnhancedType.documentOf(NestedImmutableChild.class, childSchema),
+ a -> a.name("child")
+ .getter(NestedImmutableRecord::getChild)
+ .setter(NestedImmutableRecord.Builder::child))
+ .build();
+ }
+
+ public static TableSchema buildStaticImmutableSchema_NoChildSchemaDefined() {
+ TableSchema childSchema = null;
+
+ return StaticImmutableTableSchema
+ .builder(NestedImmutableRecord.class, NestedImmutableRecord.Builder.class)
+ .newItemBuilder(NestedImmutableRecord::builder, NestedImmutableRecord.Builder::build)
+ .addAttribute(String.class, a -> a.name("id")
+ .getter(NestedImmutableRecord::getId)
+ .setter(NestedImmutableRecord.Builder::id)
+ .tags(primaryPartitionKey()))
+ .addAttribute(String.class, a -> a.name("writeAlwaysField")
+ .getter(NestedImmutableRecord::getWriteAlwaysField)
+ .setter(NestedImmutableRecord.Builder::writeAlwaysField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS)))
+ .addAttribute(String.class, a -> a.name("writeOnceField")
+ .getter(NestedImmutableRecord::getWriteOnceField)
+ .setter(NestedImmutableRecord.Builder::writeOnceField)
+ .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)))
+ .addAttribute(EnhancedType.documentOf(NestedImmutableChild.class, childSchema),
+ a -> a.name("child")
+ .getter(NestedImmutableRecord::getChild)
+ .setter(NestedImmutableRecord.Builder::child))
+ .build();
+ }
+
+ /**
+ * Creates a SimpleBean with initial values for testing update behavior.
+ */
+ public static SimpleBean createSimpleBean() {
+ return new SimpleBean()
+ .setId("1")
+ .setWriteAlwaysField("initial_writeAlways")
+ .setWriteOnceField("initial_writeOnce")
+ .setChildList(Arrays.asList(
+ new SimpleBeanChild().setId("child1")
+ .setChildAlwaysUpdate("child1_initial_writeAlways")
+ .setChildWriteOnce("child1_initial_writeOnce"),
+ new SimpleBeanChild().setId("child2")
+ .setChildAlwaysUpdate("child2_initial_writeAlways")
+ .setChildWriteOnce("child2_initial_writeOnce")));
+ }
+
+ /**
+ * Creates a NestedBean with initial values for testing update behavior.
+ */
+ public static NestedBean createNestedBean() {
+ return new NestedBean()
+ .setId("1")
+ .setWriteAlwaysField("initial_writeAlways")
+ .setWriteOnceField("initial_writeOnce")
+ .setChild(new NestedBeanChild()
+ .setChildAlwaysUpdate("child_initial_writeAlways")
+ .setChildWriteOnce("child_initial_writeOnce"));
+ }
+
+ /**
+ * Creates a SimpleImmutableRecord with initial values for testing update behavior.
+ */
+ public static SimpleImmutableRecord createSimpleImmutableRecord() {
+ return SimpleImmutableRecord
+ .builder()
+ .id("1")
+ .writeAlwaysField("initial_writeAlways")
+ .writeOnceField("initial_writeOnce")
+ .childList(Arrays.asList(
+ SimpleImmutableChild.builder()
+ .id("child1")
+ .childAlwaysUpdate("child1_initial_writeAlways")
+ .childWriteOnce("child1_initial_writeOnce")
+ .build(),
+ SimpleImmutableChild.builder()
+ .id("child2")
+ .childAlwaysUpdate("child2_initial_writeAlways")
+ .childWriteOnce("child2_initial_writeOnce")
+ .build()))
+ .build();
+ }
+
+ /**
+ * Creates a NestedImmutableRecord with initial values for testing update behavior.
+ */
+ public static NestedImmutableRecord createNestedImmutableRecord() {
+ return NestedImmutableRecord
+ .builder()
+ .id("1")
+ .writeAlwaysField("initial_writeAlways")
+ .writeOnceField("initial_writeOnce")
+ .child(NestedImmutableChild
+ .builder()
+ .childAlwaysUpdate("child_initial_writeAlways")
+ .childWriteOnce("child_initial_writeOnce")
+ .build())
+ .build();
+ }
+
+ /**
+ * Creates a SimpleStaticRecord with initial values for testing update behavior.
+ */
+ public static SimpleStaticRecord createSimpleStaticRecord() {
+ return new SimpleStaticRecord()
+ .setId("1")
+ .setWriteAlwaysField("initial_writeAlways")
+ .setWriteOnceField("initial_writeOnce");
+ }
+
+ /**
+ * Creates a NestedStaticRecord with initial values for testing update behavior.
+ */
+ public static NestedStaticRecord createNestedStaticRecord() {
+ return new NestedStaticRecord()
+ .setId("1")
+ .setWriteAlwaysField("initial_writeAlways")
+ .setWriteOnceField("initial_writeOnce")
+ .setChild(new NestedStaticChildRecord()
+ .setChildAlwaysUpdate("child_initial_writeAlways")
+ .setChildWriteOnce("child_initial_writeOnce"));
+ }
+
+ /**
+ * 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;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ 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 ChildBeanWithInvalidAttributeName child;
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ public BeanWithInvalidNestedAttributeName setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public ChildBeanWithInvalidAttributeName getChild() {
+ return child;
+ }
+
+ public BeanWithInvalidNestedAttributeName setChild(ChildBeanWithInvalidAttributeName child) {
+ this.child = child;
+ return this;
+ }
+
+ @DynamoDbBean
+ public static class ChildBeanWithInvalidAttributeName {
+ private String id;
+ private Instant childAttr_NESTED_ATTR_UPDATE_;
+
+ @DynamoDbPartitionKey
+ public String getId() {
+ return id;
+ }
+
+ public ChildBeanWithInvalidAttributeName setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
+ public Instant getChildAttr_NESTED_ATTR_UPDATE_() {
+ return childAttr_NESTED_ATTR_UPDATE_;
+ }
+
+ public ChildBeanWithInvalidAttributeName setChildAttr_NESTED_ATTR_UPDATE_(Instant childAttr_NESTED_ATTR_UPDATE_) {
+ this.childAttr_NESTED_ATTR_UPDATE_ = childAttr_NESTED_ATTR_UPDATE_;
+ return this;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java
index 6e3bbdbdc9ad..30a6e142eedf 100644
--- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java
+++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java
@@ -16,21 +16,82 @@
package software.amazon.awssdk.enhanced.dynamodb.internal;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
-import org.junit.jupiter.api.Test;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
+import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
import software.amazon.awssdk.enhanced.dynamodb.Key;
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
+import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem;
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+@RunWith(MockitoJUnitRunner.class)
public class EnhancedClientUtilsTest {
private static final AttributeValue PARTITION_VALUE = AttributeValue.builder().s("id123").build();
private static final AttributeValue SORT_VALUE = AttributeValue.builder().s("sort123").build();
+ @Mock
+ private TableSchema