From 3e5c8901c67ea87efa0d417507c2f0b9cc6f7695 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Wed, 4 Feb 2026 07:46:14 +0200 Subject: [PATCH 1/4] Added support for @DynamoDbUpdateBehavior on attributes within nested objects --- ...-AmazonDynamoDBEnhancedClient-3b3908c.json | 6 + ...AutoGeneratedTimestampRecordExtension.java | 204 ++++- .../internal/EnhancedClientUtils.java | 21 + .../extensions/utility/NestedRecordUtils.java | 140 +++ .../operations/UpdateItemOperation.java | 18 +- .../update/UpdateExpressionUtils.java | 41 +- .../annotations/DynamoDbUpdateBehavior.java | 13 +- .../NestedUpdateBehaviorTest.java | 529 ++++++++++++ .../functionaltests/UpdateBehaviorTest.java | 433 +++++++--- .../NestedRecordWithUpdateBehavior.java | 62 +- .../models/RecordWithUpdateBehaviors.java | 13 +- .../models/UpdateBehaviorTestModels.java | 813 ++++++++++++++++++ 12 files changed, 2154 insertions(+), 139 deletions(-) create mode 100644 .changes/next-release/feature-AmazonDynamoDBEnhancedClient-3b3908c.json create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/NestedUpdateBehaviorTest.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java 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..2026bc932211 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,20 @@ 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.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 +38,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -64,10 +73,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 +148,179 @@ public static AutoGeneratedTimestampRecordExtension create() { */ @Override public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + Map itemToTransform = new HashMap<>(context.items()); + + Map updatedItems = new HashMap<>(); + Instant currentInstant = clock.instant(); - Collection customMetadataObject = context.tableMetadata() - .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); + // Use TableSchema instance as the cache key + Map, TableSchema> schemaInstanceCache = new HashMap<>(); - if (customMetadataObject == null) { + itemToTransform.forEach((key, value) -> { + if (value.hasM() && value.m() != null) { + Optional> nestedSchemaOpt = getNestedSchema(context.tableSchema(), key); + if (nestedSchemaOpt.isPresent()) { + TableSchema nestedSchema = nestedSchemaOpt.get(); + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, nestedSchema); + Map processed = + processNestedObject(value.m(), cachedSchema, currentInstant, schemaInstanceCache); + updatedItems.put(key, AttributeValue.builder().m(processed).build()); + } + } else if (value.hasL() && !value.l().isEmpty()) { + // Check first non-null element to determine if this is a list of maps + AttributeValue firstElement = value.l().stream() + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + + if (firstElement != null && firstElement.hasM()) { + TableSchema elementListSchema = getTableSchemaForListElement(context.tableSchema(), key); + if (elementListSchema != null) { + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema); + Collection updatedList = new ArrayList<>(value.l().size()); + for (AttributeValue listItem : value.l()) { + if (listItem != null && listItem.hasM()) { + updatedList.add(AttributeValue.builder() + .m(processNestedObject( + listItem.m(), + cachedSchema, + currentInstant, + schemaInstanceCache)) + .build()); + } else { + updatedList.add(listItem); + } + } + updatedItems.put(key, AttributeValue.builder().l(updatedList).build()); + } + } + } + }); + + Map> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema()); + + stringTableSchemaMap.forEach((path, schema) -> { + Collection customMetadataObject = schema.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) + .orElse(null); + + if (customMetadataObject != null) { + customMetadataObject.forEach( + key -> insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key), + schema.converterForAttribute(key), 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) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key), + nestedSchema.converterForAttribute(key), currentInstant); + } + } + + for (Map.Entry entry : nestedMap.entrySet()) { + String nestedKey = entry.getKey(); + AttributeValue nestedValue = entry.getValue(); + if (nestedValue.hasM()) { + Optional> childSchemaOpt = getNestedSchema(nestedSchema, nestedKey); + if (childSchemaOpt.isPresent()) { + TableSchema childSchema = childSchemaOpt.get(); + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, childSchema); + Map processed = processNestedObject( + nestedValue.m(), cachedSchema, currentInstant, schemaInstanceCache); + + if (!Objects.equals(processed, nestedValue.m())) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + updatedNestedMap.put(nestedKey, AttributeValue.builder().m(processed).build()); + } + } + } else if (nestedValue.hasL() && !nestedValue.l().isEmpty()) { + // Check first non-null element to determine if this is a list of maps + AttributeValue firstElement = nestedValue.l().stream() + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + if (firstElement != null && firstElement.hasM()) { + 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 (listItem != null && listItem.hasM()) { + 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 +365,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 +380,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..3787e12a9a34 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 @@ -29,6 +29,7 @@ import java.util.stream.Stream; import software.amazon.awssdk.annotations.SdkInternalApi; 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; @@ -204,4 +205,24 @@ public static List getItemsFromSupplier(List> itemSupplierLis public static boolean isNullAttributeValue(AttributeValue attributeValue) { return attributeValue.nul() != null && attributeValue.nul(); } + + /** + * Retrieves the {@link TableSchema} for a nested attribute within the given parent schema. When the attribute is a + * parameterized type (e.g., List), it retrieves the schema of the first type parameter. Otherwise, it retrieves the schema + * directly from the attribute's enhanced type. + * + * @param parentSchema the schema of the parent bean class + * @param attributeName the name of the nested attribute + * @return an {@link Optional} containing the nested attribute's {@link TableSchema}, or empty if unavailable + */ + public static Optional> getNestedSchema(TableSchema parentSchema, String attributeName) { + EnhancedType enhancedType = parentSchema.converterForAttribute(attributeName).type(); + List> rawClassParameters = enhancedType.rawClassParameters(); + + if (rawClassParameters != null && !rawClassParameters.isEmpty()) { + 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..3bb378e2e6f1 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java @@ -0,0 +1,140 @@ +/* + * 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.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +@SdkInternalApi +public final class NestedRecordUtils { + + private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); + + private NestedRecordUtils() { + } + + /** + * Resolves and returns the {@link TableSchema} for the element type of a list attribute from the provided root schema. + *

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

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

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

+ * This is typically used in update or transformation flows where fields from nested objects are represented as flattened keys + * in the attribute map (e.g., {@code parent_NESTED_ATTR_UPDATE_child}). + * + * @param attributesToSet A map of flattened attribute keys to values, where keys may represent paths to nested attributes. + * @param rootSchema The root {@link TableSchema} of the top-level entity. + * @return A map where the key is the nested path (e.g., {@code "parent.child"}) and the value is the {@link TableSchema} + * corresponding to that level in the object hierarchy. + */ + public static Map> resolveSchemasPerPath(Map attributesToSet, + TableSchema rootSchema) { + Map> schemaMap = new HashMap<>(); + schemaMap.put("", rootSchema); + + for (String key : attributesToSet.keySet()) { + String[] parts = NESTED_OBJECT_PATTERN.split(key); + + StringBuilder pathBuilder = new StringBuilder(); + TableSchema currentSchema = rootSchema; + + for (int i = 0; i < parts.length - 1; i++) { + if (pathBuilder.length() > 0) { + pathBuilder.append("."); + } + pathBuilder.append(parts[i]); + + String path = pathBuilder.toString(); + + if (!schemaMap.containsKey(path)) { + Optional> nestedSchema = getNestedSchema(currentSchema, parts[i]); + if (nestedSchema.isPresent()) { + schemaMap.put(path, nestedSchema.get()); + currentSchema = nestedSchema.get(); + } + } else { + currentSchema = schemaMap.get(path); + } + } + } + return schemaMap; + } + + public static String reconstructCompositeKey(String path, String attributeName) { + if (path == null || path.isEmpty()) { + return attributeName; + } + return String.join(NESTED_OBJECT_UPDATE, path.split("\\.")) + + NESTED_OBJECT_UPDATE + attributeName; + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/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> 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..d8d2989702d3 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/NestedUpdateBehaviorTest.java @@ -0,0 +1,529 @@ +/* + * 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 software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedStaticChildRecordWithList; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticImmutableSchemaForNestedRecordWithList; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticImmutableSchemaForSimpleRecordWithList; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticSchemaForNestedRecordWithList; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticSchemaForSimpleRecordWithList; + +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.NestedBeanWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedImmutableRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedStaticRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleBeanWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleImmutableRecordWithList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleStaticRecordWithList; +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_writeIfNotExists_isRespected() { + TableSchema schema = BeanTableSchema.create(SimpleBeanWithList.class); + DynamoDbTable table = + createAndPut("simple-bean-write-if-not-exists", + schema, + new SimpleBeanWithList() + .setId("1") + .setAttr("attr_level1") + .setChildList(Arrays.asList( + new UpdateBehaviorTestModels.SimpleBeanChild().setId("child1").setAttr("attr_child1"), + new UpdateBehaviorTestModels.SimpleBeanChild().setId("child2").setAttr("attr_child2")))); + + SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getAttr()).isEqualTo("attr_level1"); + assertThat(result.getChildList()).hasSize(2); + + // update with new attr value - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) + result.setAttr("updated_level1"); + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleBeanWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(updated.getAttr()).isEqualTo("attr_level1"); // should NOT change + + table.deleteTable(); + } + + @Test + public void beanSchema_nestedRecord_writeIfNotExists_isRespected() { + TableSchema schema = BeanTableSchema.create(NestedBeanWithList.class); + DynamoDbTable table = + createAndPut("nested-bean-write-if-not-exists", + schema, + new NestedBeanWithList() + .setId("1") + .setAttr("attr_level1") + .setLevel2(new UpdateBehaviorTestModels.NestedBeanChild().setAttr("attr_level2"))); + + NestedBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getAttr()).isEqualTo("attr_level1"); + assertThat(result.getLevel2()).isNotNull(); + assertThat(result.getLevel2().getAttr()).isEqualTo("attr_level2"); + + // update with new attr values - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) + result.setAttr("updated_level1"); + result.getLevel2().setAttr("updated_level2"); + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + NestedBeanWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(updated.getAttr()).isEqualTo("attr_level1"); // should NOT change + assertThat(updated.getLevel2().getAttr()).isEqualTo("attr_level2"); // should NOT change + + table.deleteTable(); + } + + @Test + public void immutableSchema_simpleRecord_writeIfNotExists_isRespected() { + TableSchema schema = ImmutableTableSchema.create(SimpleImmutableRecordWithList.class); + DynamoDbTable table = + createAndPut("simple-immutable-write-if-not-exists", + schema, + SimpleImmutableRecordWithList + .builder() + .id("1") + .attr("attr_level1") + .childList(Arrays.asList( + UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child1").attr("attr_child1").build(), + UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child2").attr("attr_child2").build())) + .build()); + + SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getAttr()).isEqualTo("attr_level1"); + assertThat(result.getChildList()).hasSize(2); + + // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) + SimpleImmutableRecordWithList updated = SimpleImmutableRecordWithList.builder() + .id("1").attr("updated_level1") + .build(); + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(afterUpdate.getAttr()).isEqualTo("attr_level1"); // should NOT change + + table.deleteTable(); + } + + @Test + public void immutableSchema_nestedRecord_writeIfNotExists_isRespected() { + TableSchema schema = ImmutableTableSchema.create(NestedImmutableRecordWithList.class); + DynamoDbTable table = + createAndPut("nested-immutable-write-if-not-exists", + schema, + NestedImmutableRecordWithList + .builder() + .id("1") + .attr("attr_level1") + .level2(UpdateBehaviorTestModels.NestedImmutableChildRecordWithList + .builder().attr("attr_level2").build()) + .build()); + + NestedImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getAttr()).isEqualTo("attr_level1"); + assertThat(result.getLevel2()).isNotNull(); + assertThat(result.getLevel2().getAttr()).isEqualTo("attr_level2"); + + // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) + NestedImmutableRecordWithList updated = NestedImmutableRecordWithList.builder() + .id("1").attr("updated_level1") + .build(); + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + NestedImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(afterUpdate.getAttr()).isEqualTo("attr_level1"); // should NOT change + + table.deleteTable(); + } + + @Test + public void staticSchema_simpleRecord_writeIfNotExists_isRespected() { + DynamoDbTable table = + createAndPut("simple-static-write-if-not-exists", + buildStaticSchemaForSimpleRecordWithList(), + new SimpleStaticRecordWithList() + .setId("1") + .setAttr("attr_level1")); + + SimpleStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getAttr()).isEqualTo("attr_level1"); + + // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) + result.setAttr("updated_level1"); + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleStaticRecordWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(updated.getAttr()).isEqualTo("attr_level1"); // should NOT change + + table.deleteTable(); + } + + @Test + public void staticSchema_nestedRecord_writeIfNotExists_isRespected() { + DynamoDbTable table = + createAndPut( + "nested-static-write-if-not-exists", + buildStaticSchemaForNestedRecordWithList(), + new NestedStaticRecordWithList() + .setId("1") + .setAttr("attr_level1") + .setLevel2(new NestedStaticChildRecordWithList().setAttr("attr_level2"))); + + NestedStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getAttr()).isEqualTo("attr_level1"); + assertThat(result.getLevel2()).isNotNull(); + assertThat(result.getLevel2().getAttr()).isEqualTo("attr_level2"); + + // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) + result.setAttr("updated_level1"); + result.getLevel2().setAttr("updated_level2"); + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + NestedStaticRecordWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(updated.getAttr()).isEqualTo("attr_level1"); // should NOT change + assertThat(updated.getLevel2().getAttr()).isEqualTo("attr_level2"); // should NOT change + + table.deleteTable(); + } + + @Test + public void staticImmutableSchema_simpleRecord_writeIfNotExists_isRespected() { + DynamoDbTable table = + createAndPut("simple-static-immutable-write-if-not-exists", + buildStaticImmutableSchemaForSimpleRecordWithList(), + SimpleImmutableRecordWithList + .builder() + .id("1") + .attr("attr_level1") + .childList(Arrays.asList( + UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child1").attr("attr_child1").build(), + UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child2").attr("attr_child2").build())) + .build()); + + SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getAttr()).isEqualTo("attr_level1"); + + // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) + SimpleImmutableRecordWithList updated = SimpleImmutableRecordWithList.builder() + .id("1").attr("updated_level1") + .build(); + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(afterUpdate.getAttr()).isEqualTo("attr_level1"); // should NOT change + + table.deleteTable(); + } + + @Test + public void staticImmutableSchema_nestedRecord_writeIfNotExists_isRespected() { + DynamoDbTable table = + createAndPut("nested-static-immutable-write-if-not-exists", + buildStaticImmutableSchemaForNestedRecordWithList(), + NestedImmutableRecordWithList + .builder() + .id("1") + .attr("attr_level1") + .level2(UpdateBehaviorTestModels.NestedImmutableChildRecordWithList + .builder().attr("attr_level2").build()) + .build()); + + NestedImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getAttr()).isEqualTo("attr_level1"); + assertThat(result.getLevel2()).isNotNull(); + assertThat(result.getLevel2().getAttr()).isEqualTo("attr_level2"); + + // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) + NestedImmutableRecordWithList updated = NestedImmutableRecordWithList.builder() + .id("1").attr("updated_level1") + .build(); + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + NestedImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(afterUpdate.getAttr()).isEqualTo("attr_level1"); // should NOT change + + table.deleteTable(); + } + + @Test + public void beanSchema_simpleRecord_writeAlways_updatesValues() { + TableSchema schema = BeanTableSchema.create(SimpleBeanWithList.class); + SimpleBeanWithList initial = new SimpleBeanWithList() + .setId("1") + .setAttr("attr_level1") + .setChildList(Arrays.asList( + new UpdateBehaviorTestModels.SimpleBeanChild().setId("child1").setAttr("attr_child1"), + new UpdateBehaviorTestModels.SimpleBeanChild().setId("child2").setAttr("attr_child2"))); + initial.setId("initial_id"); + DynamoDbTable table = createAndPut("simple-bean-write-always", schema, initial); + + SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("initial_id"))); + assertThat(result.getId()).isEqualTo("initial_id"); + + // update id (no annotation, defaults to WRITE_ALWAYS) - should change + result.setId("updated_id"); + table.updateItem(result); + + SimpleBeanWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); + assertThat(updated).isNotNull(); + assertThat(updated.getId()).isEqualTo("updated_id"); + + table.deleteTable(); + } + + @Test + public void beanSchema_nestedRecord_writeAlways_updatesValues() { + TableSchema schema = BeanTableSchema.create(NestedBeanWithList.class); + NestedBeanWithList initial = new NestedBeanWithList() + .setId("1") + .setAttr("attr_level1") + .setLevel2(new UpdateBehaviorTestModels.NestedBeanChild().setAttr("attr_level2")); + initial.setId("initial_id"); + DynamoDbTable table = createAndPut("nested-bean-write-always", schema, initial); + + NestedBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("initial_id"))); + assertThat(result.getId()).isEqualTo("initial_id"); + + // update id - should change (WRITE_ALWAYS is default) + result.setId("updated_id"); + table.updateItem(result); + + NestedBeanWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); + assertThat(updated).isNotNull(); + assertThat(updated.getId()).isEqualTo("updated_id"); + + table.deleteTable(); + } + + @Test + public void immutableSchema_simpleRecord_writeAlways_updatesValues() { + TableSchema schema = ImmutableTableSchema.create(SimpleImmutableRecordWithList.class); + SimpleImmutableRecordWithList initial = + SimpleImmutableRecordWithList + .builder() + .id("1") + .attr("attr_level1") + .childList(Arrays.asList( + UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child1").attr("attr_child1").build(), + UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child2").attr("attr_child2").build())) + .build(); + + DynamoDbTable table = createAndPut("simple-immutable-write-always", schema, initial); + + SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getId()).isEqualTo("1"); + + // update with new id - should change (WRITE_ALWAYS is default) + SimpleImmutableRecordWithList updated = SimpleImmutableRecordWithList.builder() + .id("updated_id").attr("attr_level1") + .build(); + table.updateItem(updated); + + SimpleImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); + assertThat(afterUpdate).isNotNull(); + assertThat(afterUpdate.getId()).isEqualTo("updated_id"); + + table.deleteTable(); + } + + @Test + public void immutableSchema_nestedRecord_writeAlways_updatesValues() { + TableSchema schema = ImmutableTableSchema.create(NestedImmutableRecordWithList.class); + NestedImmutableRecordWithList initial = + NestedImmutableRecordWithList + .builder() + .id("1") + .attr("attr_level1") + .level2(UpdateBehaviorTestModels.NestedImmutableChildRecordWithList + .builder().attr("attr_level2").build()) + .build(); + DynamoDbTable table = createAndPut("nested-immutable-write-always", schema, initial); + + NestedImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getId()).isEqualTo("1"); + + // update with new id - should change (WRITE_ALWAYS is default) + NestedImmutableRecordWithList updated = NestedImmutableRecordWithList.builder() + .id("updated_id").attr("attr_level1") + .build(); + table.updateItem(updated); + + NestedImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); + assertThat(afterUpdate).isNotNull(); + assertThat(afterUpdate.getId()).isEqualTo("updated_id"); + + table.deleteTable(); + } + + @Test + public void staticSchema_simpleRecord_writeAlways_updatesValues() { + DynamoDbTable table = + createAndPut("simple-static-write-always", + buildStaticSchemaForSimpleRecordWithList(), + new SimpleStaticRecordWithList() + .setId("1") + .setAttr("attr_level1")); + + SimpleStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getId()).isEqualTo("1"); + + // update id - should change (WRITE_ALWAYS is default) + result.setId("updated_id"); + table.updateItem(result); + + SimpleStaticRecordWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); + assertThat(updated).isNotNull(); + assertThat(updated.getId()).isEqualTo("updated_id"); + + table.deleteTable(); + } + + @Test + public void staticSchema_nestedRecord_writeAlways_updatesValues() { + DynamoDbTable table = createAndPut( + "nested-static-write-always", + buildStaticSchemaForNestedRecordWithList(), + new NestedStaticRecordWithList() + .setId("1") + .setAttr("attr_level1") + .setLevel2(new UpdateBehaviorTestModels.NestedStaticChildRecordWithList().setAttr("attr_level2"))); + + NestedStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getId()).isEqualTo("1"); + + // update id - should change (WRITE_ALWAYS is default) + result.setId("updated_id"); + table.updateItem(result); + + NestedStaticRecordWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); + assertThat(updated).isNotNull(); + assertThat(updated.getId()).isEqualTo("updated_id"); + + table.deleteTable(); + } + + @Test + public void staticImmutableSchema_simpleRecord_writeAlways_updatesValues() { + DynamoDbTable table = + createAndPut("simple-static-immutable-write-always", + buildStaticImmutableSchemaForSimpleRecordWithList(), + SimpleImmutableRecordWithList + .builder() + .id("1") + .attr("attr_level1") + .childList(Arrays.asList( + UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child1").attr("attr_child1").build(), + UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child2").attr("attr_child2").build())) + .build()); + + SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getId()).isEqualTo("1"); + + // update with new id - should change (WRITE_ALWAYS is default) + SimpleImmutableRecordWithList updated = SimpleImmutableRecordWithList.builder() + .id("updated_id").attr("attr_level1") + .build(); + table.updateItem(updated); + + SimpleImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); + assertThat(afterUpdate).isNotNull(); + assertThat(afterUpdate.getId()).isEqualTo("updated_id"); + + table.deleteTable(); + } + + @Test + public void staticImmutableSchema_nestedRecord_writeAlways_updatesValues() { + DynamoDbTable table = + createAndPut("nested-static-immutable-write-always", + buildStaticImmutableSchemaForNestedRecordWithList(), + NestedImmutableRecordWithList.builder() + .id("1") + .attr("attr_level1") + .level2(UpdateBehaviorTestModels.NestedImmutableChildRecordWithList + .builder().attr("attr_level2").build()) + .build()); + + NestedImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getId()).isEqualTo("1"); + + // update with new id - should change (WRITE_ALWAYS is default) + NestedImmutableRecordWithList updated = NestedImmutableRecordWithList.builder() + .id("updated_id").attr("attr_level1") + .build(); + table.updateItem(updated); + + NestedImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); + assertThat(afterUpdate).isNotNull(); + assertThat(afterUpdate.getId()).isEqualTo("updated_id"); + + table.deleteTable(); + } + + 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..f246488903ca 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.BeanWithInvalidNestedAttributeNameChild; +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 'attr_NESTED_ATTR_UPDATE_' contains reserved marker " + + "'_NESTED_ATTR_UPDATE_' and is not allowed."); + + BeanWithInvalidNestedAttributeName record = new BeanWithInvalidNestedAttributeName(); + record.setId("1"); + + BeanWithInvalidNestedAttributeNameChild childBean = new BeanWithInvalidNestedAttributeNameChild(); + childBean.setId("2"); + childBean.setAttr_NESTED_ATTR_UPDATE_(INSTANT_1); + record.setNestedChildAttribute(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..5c84edc99755 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java @@ -0,0 +1,813 @@ +/* + * 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.Collections; +import java.util.List; +import java.util.Objects; +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 specifically designed for update behavior functionality testing. These models focus on the "attr" attribute + * annotated with @DynamoDbUpdateBehavior annotation and are used by NestedUpdateBehaviorTest. + */ +public final class UpdateBehaviorTestModels { + + private UpdateBehaviorTestModels() { + } + + @DynamoDbBean + public static class SimpleBeanWithList { + private String id; + private String attr; + private List childList; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public SimpleBeanWithList setId(String id) { + this.id = id; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getAttr() { + return attr; + } + + public SimpleBeanWithList setAttr(String attr) { + this.attr = attr; + return this; + } + + public List getChildList() { + return childList; + } + + public SimpleBeanWithList setChildList(List childList) { + this.childList = childList; + return this; + } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof SimpleBeanWithList)) { + return false; + } + + SimpleBeanWithList that = (SimpleBeanWithList) o; + return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) && Objects.equals(childList, that.childList); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(id); + result = 31 * result + Objects.hashCode(attr); + result = 31 * result + Objects.hashCode(childList); + return result; + } + } + + @DynamoDbBean + public static class SimpleBeanChild { + private String id; + private String attr; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public SimpleBeanChild setId(String id) { + this.id = id; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getAttr() { + return attr; + } + + public SimpleBeanChild setAttr(String attr) { + this.attr = attr; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SimpleBeanChild that = (SimpleBeanChild) o; + return Objects.equals(id, that.id) && Objects.equals(attr, that.attr); + } + + @Override + public int hashCode() { + return Objects.hash(id, attr); + } + } + + @DynamoDbBean + public static class NestedBeanWithList { + private String id; + private String attr; + private NestedBeanChild level2; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public NestedBeanWithList setId(String id) { + this.id = id; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getAttr() { + return attr; + } + + public NestedBeanWithList setAttr(String attr) { + this.attr = attr; + return this; + } + + public NestedBeanChild getLevel2() { + return level2; + } + + public NestedBeanWithList setLevel2(NestedBeanChild level2) { + this.level2 = level2; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NestedBeanWithList that = (NestedBeanWithList) o; + return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) + && Objects.equals(level2, that.level2); + } + + @Override + public int hashCode() { + return Objects.hash(id, attr, level2); + } + } + + @DynamoDbBean + public static class NestedBeanChild { + private String attr; + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getAttr() { + return attr; + } + + public NestedBeanChild setAttr(String attr) { + this.attr = attr; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NestedBeanChild that = (NestedBeanChild) o; + return Objects.equals(attr, that.attr); + } + + @Override + public int hashCode() { + return Objects.hash(attr + ); + } + } + + @DynamoDbImmutable(builder = SimpleImmutableRecordWithList.Builder.class) + public static final class SimpleImmutableRecordWithList { + private final String id; + private final String attr; + private final List childList; + + private SimpleImmutableRecordWithList(Builder b) { + this.id = b.id; + this.attr = b.attr; + this.childList = b.childList; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getAttr() { + return attr; + } + + 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 attr; + private List childList; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder attr(String attr) { + this.attr = attr; + return this; + } + + public Builder childList(List childList) { + this.childList = childList; + return this; + } + + public SimpleImmutableRecordWithList build() { + return new SimpleImmutableRecordWithList(this); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SimpleImmutableRecordWithList that = (SimpleImmutableRecordWithList) o; + return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) + && Objects.equals(childList, that.childList); + } + + @Override + public int hashCode() { + return Objects.hash(id, attr, childList); + } + } + + @DynamoDbImmutable(builder = SimpleImmutableChild.Builder.class) + public static final class SimpleImmutableChild { + private final String id; + private final String attr; + + private SimpleImmutableChild(Builder b) { + this.id = b.id; + this.attr = b.attr; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getAttr() { + return attr; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private String attr; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder attr(String attr) { + this.attr = attr; + return this; + } + + public SimpleImmutableChild build() { + return new SimpleImmutableChild(this); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SimpleImmutableChild that = (SimpleImmutableChild) o; + return Objects.equals(id, that.id) && Objects.equals(attr, that.attr); + } + + @Override + public int hashCode() { + return Objects.hash(id, attr); + } + } + + @DynamoDbImmutable(builder = NestedImmutableRecordWithList.Builder.class) + public static final class NestedImmutableRecordWithList { + private final String id; + private final String attr; + private final NestedImmutableChildRecordWithList level2; + + private NestedImmutableRecordWithList(Builder b) { + this.id = b.id; + this.attr = b.attr; + + this.level2 = b.level2; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getAttr() { + return attr; + } + + public NestedImmutableChildRecordWithList getLevel2() { + return level2; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private String attr; + private NestedImmutableChildRecordWithList level2; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder attr(String attr) { + this.attr = attr; + return this; + } + + public Builder level2(NestedImmutableChildRecordWithList level2) { + this.level2 = level2; + return this; + } + + public NestedImmutableRecordWithList build() { + return new NestedImmutableRecordWithList(this); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NestedImmutableRecordWithList that = (NestedImmutableRecordWithList) o; + return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) + && Objects.equals(level2, that.level2); + } + + @Override + public int hashCode() { + return Objects.hash(id, attr, level2); + } + } + + @DynamoDbImmutable(builder = NestedImmutableChildRecordWithList.Builder.class) + public static final class NestedImmutableChildRecordWithList { + private final String attr; + + private NestedImmutableChildRecordWithList(Builder b) { + this.attr = b.attr; + + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getAttr() { + return attr; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String attr; + + public Builder attr(String attr) { + this.attr = attr; + return this; + } + + public NestedImmutableChildRecordWithList build() { + return new NestedImmutableChildRecordWithList(this); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NestedImmutableChildRecordWithList that = (NestedImmutableChildRecordWithList) o; + return Objects.equals(attr, that.attr); + } + + @Override + public int hashCode() { + return Objects.hash(attr); + } + } + + public static class SimpleStaticRecordWithList { + private String id; + private String attr; + + public String getId() { + return id; + } + + public SimpleStaticRecordWithList setId(String id) { + this.id = id; + return this; + } + + public String getAttr() { + return attr; + } + + public SimpleStaticRecordWithList setAttr(String attr) { + this.attr = attr; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SimpleStaticRecordWithList that = (SimpleStaticRecordWithList) o; + return Objects.equals(id, that.id) && Objects.equals(attr, that.attr); + } + + @Override + public int hashCode() { + return Objects.hash(id, attr); + } + } + + public static class NestedStaticRecordWithList { + private String id; + private String attr; + private NestedStaticChildRecordWithList level2; + + public String getId() { + return id; + } + + public NestedStaticRecordWithList setId(String id) { + this.id = id; + return this; + } + + public String getAttr() { + return attr; + } + + public NestedStaticRecordWithList setAttr(String attr) { + this.attr = attr; + return this; + } + + public NestedStaticChildRecordWithList getLevel2() { + return level2; + } + + public NestedStaticRecordWithList setLevel2(NestedStaticChildRecordWithList level2) { + this.level2 = level2; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NestedStaticRecordWithList that = (NestedStaticRecordWithList) o; + return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) + && Objects.equals(level2, that.level2); + } + + @Override + public int hashCode() { + return Objects.hash(id, attr, level2); + } + } + + public static class NestedStaticChildRecordWithList { + private String attr; + + public String getAttr() { + return attr; + } + + public NestedStaticChildRecordWithList setAttr(String attr) { + this.attr = attr; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NestedStaticChildRecordWithList that = (NestedStaticChildRecordWithList) o; + return Objects.equals(attr, that.attr); + } + + @Override + public int hashCode() { + return Objects.hash(attr); + } + } + + public static TableSchema buildStaticSchemaForSimpleRecordWithList() { + return StaticTableSchema.builder(SimpleStaticRecordWithList.class) + .newItemSupplier(SimpleStaticRecordWithList::new) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleStaticRecordWithList::getId) + .setter(SimpleStaticRecordWithList::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attr") + .getter(SimpleStaticRecordWithList::getAttr) + .setter(SimpleStaticRecordWithList::setAttr) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .build(); + } + + public static TableSchema buildStaticSchemaForNestedRecordWithList() { + TableSchema level2Schema = + StaticTableSchema.builder(NestedStaticChildRecordWithList.class) + .newItemSupplier(NestedStaticChildRecordWithList::new) + .addAttribute(String.class, a -> a.name("attr") + .getter(NestedStaticChildRecordWithList::getAttr) + .setter(NestedStaticChildRecordWithList::setAttr) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .build(); + + return StaticTableSchema.builder(NestedStaticRecordWithList.class) + .newItemSupplier(NestedStaticRecordWithList::new) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedStaticRecordWithList::getId) + .setter(NestedStaticRecordWithList::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attr") + .getter(NestedStaticRecordWithList::getAttr) + .setter(NestedStaticRecordWithList::setAttr) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(EnhancedType.documentOf(NestedStaticChildRecordWithList.class, level2Schema), + a -> a.name("level2") + .getter(NestedStaticRecordWithList::getLevel2) + .setter(NestedStaticRecordWithList::setLevel2)) + .build(); + } + + public static TableSchema buildStaticImmutableSchemaForSimpleRecordWithList() { + TableSchema childSchema = + StaticImmutableTableSchema.builder(SimpleImmutableChild.class, + SimpleImmutableChild.Builder.class) + .newItemBuilder(SimpleImmutableChild::builder, + SimpleImmutableChild.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleImmutableChild::getId) + .setter(SimpleImmutableChild.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attr") + .getter(SimpleImmutableChild::getAttr) + .setter(SimpleImmutableChild.Builder::attr) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .build(); + + return StaticImmutableTableSchema.builder(SimpleImmutableRecordWithList.class, + SimpleImmutableRecordWithList.Builder.class) + .newItemBuilder(SimpleImmutableRecordWithList::builder, + SimpleImmutableRecordWithList.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleImmutableRecordWithList::getId) + .setter(SimpleImmutableRecordWithList.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attr") + .getter(SimpleImmutableRecordWithList::getAttr) + .setter(SimpleImmutableRecordWithList.Builder::attr) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(EnhancedType.listOf(EnhancedType.documentOf(SimpleImmutableChild.class, + childSchema)), + a -> a.name("childList") + .getter(SimpleImmutableRecordWithList::getChildList) + .setter(SimpleImmutableRecordWithList.Builder::childList)) + .build(); + } + + public static TableSchema buildStaticImmutableSchemaForNestedRecordWithList() { + TableSchema level2Schema = + StaticImmutableTableSchema.builder(NestedImmutableChildRecordWithList.class, + NestedImmutableChildRecordWithList.Builder.class) + .newItemBuilder(NestedImmutableChildRecordWithList::builder, + NestedImmutableChildRecordWithList.Builder::build) + .addAttribute(String.class, a -> a.name("attr") + .getter(NestedImmutableChildRecordWithList::getAttr) + .setter(NestedImmutableChildRecordWithList.Builder::attr) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .build(); + + return StaticImmutableTableSchema.builder(NestedImmutableRecordWithList.class, + NestedImmutableRecordWithList.Builder.class) + .newItemBuilder(NestedImmutableRecordWithList::builder, + NestedImmutableRecordWithList.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedImmutableRecordWithList::getId) + .setter(NestedImmutableRecordWithList.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("attr") + .getter(NestedImmutableRecordWithList::getAttr) + .setter(NestedImmutableRecordWithList.Builder::attr) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(EnhancedType.documentOf(NestedImmutableChildRecordWithList.class, + level2Schema), + a -> a.name("level2") + .getter(NestedImmutableRecordWithList::getLevel2) + .setter(NestedImmutableRecordWithList.Builder::level2)) + .build(); + } + + /** + * Test model with an invalid root-level attribute name containing the reserved '_NESTED_ATTR_UPDATE_' pattern. + * Used to test validation of attribute names that conflict with internal DynamoDB Enhanced Client conventions. + */ + @DynamoDbBean + public static class BeanWithInvalidRootAttributeName { + private String id; + private Instant attr_NESTED_ATTR_UPDATE_; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithInvalidRootAttributeName setId(String id) { + this.id = id; + return this; + } + + @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 BeanWithInvalidNestedAttributeNameChild nestedChildAttribute; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithInvalidNestedAttributeName setId(String id) { + this.id = id; + return this; + } + + public BeanWithInvalidNestedAttributeNameChild getNestedChildAttribute() { + return nestedChildAttribute; + } + + public BeanWithInvalidNestedAttributeName setNestedChildAttribute( + BeanWithInvalidNestedAttributeNameChild nestedChildAttribute) { + this.nestedChildAttribute = nestedChildAttribute; + return this; + } + + @DynamoDbBean + public static class BeanWithInvalidNestedAttributeNameChild { + private String id; + private BeanWithInvalidNestedAttributeNameChild nestedChildAttribute; + private Instant childAttr_NESTED_ATTR_UPDATE_; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithInvalidNestedAttributeNameChild setId(String id) { + this.id = id; + return this; + } + + public BeanWithInvalidNestedAttributeNameChild getNestedChildAttribute() { + return nestedChildAttribute; + } + + public BeanWithInvalidNestedAttributeNameChild setNestedChildAttribute( + BeanWithInvalidNestedAttributeNameChild nestedChildAttribute) { + this.nestedChildAttribute = nestedChildAttribute; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public Instant getAttr_NESTED_ATTR_UPDATE_() { + return childAttr_NESTED_ATTR_UPDATE_; + } + + public BeanWithInvalidNestedAttributeNameChild setAttr_NESTED_ATTR_UPDATE_(Instant attr_NESTED_ATTR_UPDATE_) { + this.childAttr_NESTED_ATTR_UPDATE_ = attr_NESTED_ATTR_UPDATE_; + return this; + } + } + } +} \ No newline at end of file From 34f097bf011d1f348773cc66da6a35ecf2c407fb Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Mon, 9 Feb 2026 09:36:53 +0200 Subject: [PATCH 2/4] Added support for @DynamoDbUpdateBehavior on attributes within nested objects --- ...AutoGeneratedTimestampRecordExtension.java | 33 +- .../internal/EnhancedClientUtils.java | 43 +- .../extensions/utility/NestedRecordUtils.java | 42 +- .../NestedUpdateBehaviorTest.java | 653 ++++++------- .../functionaltests/UpdateBehaviorTest.java | 10 +- .../models/UpdateBehaviorTestModels.java | 866 +++++++++--------- .../internal/EnhancedClientUtilsTest.java | 334 ++++++- .../utility/NestedRecordUtilsTest.java | 389 ++++++++ 8 files changed, 1546 insertions(+), 824 deletions(-) create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java 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 2026bc932211..07794d67450e 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java @@ -16,6 +16,7 @@ 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; @@ -157,7 +158,7 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex Map, TableSchema> schemaInstanceCache = new HashMap<>(); itemToTransform.forEach((key, value) -> { - if (value.hasM() && value.m() != null) { + if (hasMap(value)) { Optional> nestedSchemaOpt = getNestedSchema(context.tableSchema(), key); if (nestedSchemaOpt.isPresent()) { TableSchema nestedSchema = nestedSchemaOpt.get(); @@ -173,13 +174,13 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .findFirst() .orElse(null); - if (firstElement != null && firstElement.hasM()) { + if (hasMap(firstElement)) { TableSchema elementListSchema = getTableSchemaForListElement(context.tableSchema(), key); if (elementListSchema != null) { TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema); Collection updatedList = new ArrayList<>(value.l().size()); for (AttributeValue listItem : value.l()) { - if (listItem != null && listItem.hasM()) { + if (hasMap(listItem)) { updatedList.add(AttributeValue.builder() .m(processNestedObject( listItem.m(), @@ -206,8 +207,13 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex if (customMetadataObject != null) { customMetadataObject.forEach( - key -> insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key), - schema.converterForAttribute(key), currentInstant)); + key -> { + AttributeConverter converter = schema.converterForAttribute(key); + if (converter != null) { + insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key), + converter, currentInstant); + } + }); } }); @@ -245,12 +251,15 @@ private Map processNestedObject(Map(nestedMap); - updated = true; + AttributeConverter converter = nestedSchema.converterForAttribute(key); + if (converter != null) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key), + converter, currentInstant); } - insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key), - nestedSchema.converterForAttribute(key), currentInstant); } } @@ -279,14 +288,14 @@ private Map processNestedObject(Map 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 (listItem != null && listItem.hasM()) { + if (hasMap(listItem)) { AttributeValue updatedItem = AttributeValue.builder() .m(processNestedObject( listItem.m(), 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 3787e12a9a34..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,6 +28,7 @@ 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; @@ -38,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 { @@ -147,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(); @@ -206,20 +209,38 @@ 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 {@link TableSchema} for a nested attribute within the given parent schema. When the attribute is a - * parameterized type (e.g., List), it retrieves the schema of the first type parameter. Otherwise, it retrieves the schema - * directly from the attribute's enhanced type. + * 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 schema of the parent bean class - * @param attributeName the name of the nested attribute - * @return an {@link Optional} containing the nested attribute's {@link TableSchema}, or empty if unavailable + * @param parentSchema the parent schema; must not be null + * @param attributeName the attribute name; must not be null or empty + * @return the nested schema, or empty if unavailable */ - public static Optional> getNestedSchema(TableSchema parentSchema, String attributeName) { - EnhancedType enhancedType = parentSchema.converterForAttribute(attributeName).type(); - List> rawClassParameters = enhancedType.rawClassParameters(); + public static Optional> getNestedSchema(TableSchema parentSchema, CharSequence attributeName) { + if (parentSchema == null) { + throw new IllegalArgumentException("Parent schema cannot be null."); + } + if (StringUtils.isEmpty(attributeName)) { + throw new IllegalArgumentException("Attribute name cannot be null or empty."); + } - if (rawClassParameters != null && !rawClassParameters.isEmpty()) { + 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); } 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 index 3bb378e2e6f1..0cdfa3577922 100644 --- 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 @@ -19,12 +19,17 @@ 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 { @@ -35,7 +40,7 @@ private NestedRecordUtils() { } /** - * Resolves and returns the {@link TableSchema} for the element type of a list attribute from the provided root schema. + * 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. @@ -54,11 +59,20 @@ public static TableSchema getTableSchemaForListElement(TableSchema rootSch try { if (!key.contains(NESTED_OBJECT_UPDATE)) { Optional> staticSchema = getNestedSchema(rootSchema, key); - listElementSchema = - staticSchema.isPresent() - ? staticSchema.get() - : TableSchema.fromClass(Class.forName( - rootSchema.converterForAttribute(key).type().rawClassParameters().get(0).rawClass().getName())); + 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); @@ -130,10 +144,24 @@ public static Map> resolveSchemasPerPath(Map schema = BeanTableSchema.create(SimpleBeanWithList.class); - DynamoDbTable table = - createAndPut("simple-bean-write-if-not-exists", - schema, - new SimpleBeanWithList() - .setId("1") - .setAttr("attr_level1") - .setChildList(Arrays.asList( - new UpdateBehaviorTestModels.SimpleBeanChild().setId("child1").setAttr("attr_child1"), - new UpdateBehaviorTestModels.SimpleBeanChild().setId("child2").setAttr("attr_child2")))); - - SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getAttr()).isEqualTo("attr_level1"); - assertThat(result.getChildList()).hasSize(2); + public void beanSchema_simpleRecord_updateBehavior_isRespected() { + TableSchema schema = BeanTableSchema.create(SimpleBean.class); - // update with new attr value - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) - result.setAttr("updated_level1"); - table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + SimpleBean item = UpdateBehaviorTestModels.createSimpleBean(); - SimpleBeanWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(updated.getAttr()).isEqualTo("attr_level1"); // should NOT change + DynamoDbTable table = createAndPut("simple-bean-update-behavior", schema, item); - table.deleteTable(); - } + 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"); - @Test - public void beanSchema_nestedRecord_writeIfNotExists_isRespected() { - TableSchema schema = BeanTableSchema.create(NestedBeanWithList.class); - DynamoDbTable table = - createAndPut("nested-bean-write-if-not-exists", - schema, - new NestedBeanWithList() - .setId("1") - .setAttr("attr_level1") - .setLevel2(new UpdateBehaviorTestModels.NestedBeanChild().setAttr("attr_level2"))); - - NestedBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getAttr()).isEqualTo("attr_level1"); - assertThat(result.getLevel2()).isNotNull(); - assertThat(result.getLevel2().getAttr()).isEqualTo("attr_level2"); - - // update with new attr values - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) - result.setAttr("updated_level1"); - result.getLevel2().setAttr("updated_level2"); table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - NestedBeanWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(updated.getAttr()).isEqualTo("attr_level1"); // should NOT change - assertThat(updated.getLevel2().getAttr()).isEqualTo("attr_level2"); // should NOT change + 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 immutableSchema_simpleRecord_writeIfNotExists_isRespected() { - TableSchema schema = ImmutableTableSchema.create(SimpleImmutableRecordWithList.class); - DynamoDbTable table = - createAndPut("simple-immutable-write-if-not-exists", - schema, - SimpleImmutableRecordWithList - .builder() - .id("1") - .attr("attr_level1") - .childList(Arrays.asList( - UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child1").attr("attr_child1").build(), - UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child2").attr("attr_child2").build())) - .build()); - - SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getAttr()).isEqualTo("attr_level1"); - assertThat(result.getChildList()).hasSize(2); + public void beanSchema_nestedRecord_updateBehavior_isRespected() { + TableSchema schema = BeanTableSchema.create(NestedBean.class); - // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) - SimpleImmutableRecordWithList updated = SimpleImmutableRecordWithList.builder() - .id("1").attr("updated_level1") - .build(); - table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + NestedBean item = UpdateBehaviorTestModels.createNestedBean(); - SimpleImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(afterUpdate.getAttr()).isEqualTo("attr_level1"); // should NOT change + DynamoDbTable table = createAndPut("nested-bean-update-behavior", schema, item); - table.deleteTable(); - } + 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"); - @Test - public void immutableSchema_nestedRecord_writeIfNotExists_isRespected() { - TableSchema schema = ImmutableTableSchema.create(NestedImmutableRecordWithList.class); - DynamoDbTable table = - createAndPut("nested-immutable-write-if-not-exists", - schema, - NestedImmutableRecordWithList - .builder() - .id("1") - .attr("attr_level1") - .level2(UpdateBehaviorTestModels.NestedImmutableChildRecordWithList - .builder().attr("attr_level2").build()) - .build()); - - NestedImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getAttr()).isEqualTo("attr_level1"); - assertThat(result.getLevel2()).isNotNull(); - assertThat(result.getLevel2().getAttr()).isEqualTo("attr_level2"); - - // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) - NestedImmutableRecordWithList updated = NestedImmutableRecordWithList.builder() - .id("1").attr("updated_level1") - .build(); - table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + // 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"); - NestedImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(afterUpdate.getAttr()).isEqualTo("attr_level1"); // should NOT change + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - table.deleteTable(); - } + NestedBean updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - @Test - public void staticSchema_simpleRecord_writeIfNotExists_isRespected() { - DynamoDbTable table = - createAndPut("simple-static-write-if-not-exists", - buildStaticSchemaForSimpleRecordWithList(), - new SimpleStaticRecordWithList() - .setId("1") - .setAttr("attr_level1")); - - SimpleStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getAttr()).isEqualTo("attr_level1"); - - // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) - result.setAttr("updated_level1"); - table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + // 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 - SimpleStaticRecordWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(updated.getAttr()).isEqualTo("attr_level1"); // 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 staticSchema_nestedRecord_writeIfNotExists_isRespected() { - DynamoDbTable table = - createAndPut( - "nested-static-write-if-not-exists", - buildStaticSchemaForNestedRecordWithList(), - new NestedStaticRecordWithList() - .setId("1") - .setAttr("attr_level1") - .setLevel2(new NestedStaticChildRecordWithList().setAttr("attr_level2"))); - - NestedStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getAttr()).isEqualTo("attr_level1"); - assertThat(result.getLevel2()).isNotNull(); - assertThat(result.getLevel2().getAttr()).isEqualTo("attr_level2"); - - // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) - result.setAttr("updated_level1"); - result.getLevel2().setAttr("updated_level2"); - table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - - NestedStaticRecordWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(updated.getAttr()).isEqualTo("attr_level1"); // should NOT change - assertThat(updated.getLevel2().getAttr()).isEqualTo("attr_level2"); // should NOT change + 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); - table.deleteTable(); - } + // 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(); - @Test - public void staticImmutableSchema_simpleRecord_writeIfNotExists_isRespected() { - DynamoDbTable table = - createAndPut("simple-static-immutable-write-if-not-exists", - buildStaticImmutableSchemaForSimpleRecordWithList(), - SimpleImmutableRecordWithList - .builder() - .id("1") - .attr("attr_level1") - .childList(Arrays.asList( - UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child1").attr("attr_child1").build(), - UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child2").attr("attr_child2").build())) - .build()); - - SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getAttr()).isEqualTo("attr_level1"); - - // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) - SimpleImmutableRecordWithList updated = SimpleImmutableRecordWithList.builder() - .id("1").attr("updated_level1") - .build(); table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - SimpleImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(afterUpdate.getAttr()).isEqualTo("attr_level1"); // should NOT change + 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 staticImmutableSchema_nestedRecord_writeIfNotExists_isRespected() { - DynamoDbTable table = - createAndPut("nested-static-immutable-write-if-not-exists", - buildStaticImmutableSchemaForNestedRecordWithList(), - NestedImmutableRecordWithList - .builder() - .id("1") - .attr("attr_level1") - .level2(UpdateBehaviorTestModels.NestedImmutableChildRecordWithList - .builder().attr("attr_level2").build()) - .build()); - - NestedImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getAttr()).isEqualTo("attr_level1"); - assertThat(result.getLevel2()).isNotNull(); - assertThat(result.getLevel2().getAttr()).isEqualTo("attr_level2"); - - // update with new attr - should NOT change due to WRITE_IF_NOT_EXISTS (requires SCALAR_ONLY mode) - NestedImmutableRecordWithList updated = NestedImmutableRecordWithList.builder() - .id("1").attr("updated_level1") - .build(); + 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)); - NestedImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(afterUpdate.getAttr()).isEqualTo("attr_level1"); // should NOT change + 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 beanSchema_simpleRecord_writeAlways_updatesValues() { - TableSchema schema = BeanTableSchema.create(SimpleBeanWithList.class); - SimpleBeanWithList initial = new SimpleBeanWithList() + public void staticSchema_simpleRecord_updateBehavior_isRespected() { + SimpleStaticRecord item = new SimpleStaticRecord() .setId("1") - .setAttr("attr_level1") - .setChildList(Arrays.asList( - new UpdateBehaviorTestModels.SimpleBeanChild().setId("child1").setAttr("attr_child1"), - new UpdateBehaviorTestModels.SimpleBeanChild().setId("child2").setAttr("attr_child2"))); - initial.setId("initial_id"); - DynamoDbTable table = createAndPut("simple-bean-write-always", schema, initial); + .setWriteAlwaysField("initial_writeAlways") + .setWriteOnceField("initial_writeOnce"); - SimpleBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("initial_id"))); - assertThat(result.getId()).isEqualTo("initial_id"); + DynamoDbTable table = createAndPut("simple-static-update-behavior", + buildStaticSchemaForSimpleRecord(), item); - // update id (no annotation, defaults to WRITE_ALWAYS) - should change - result.setId("updated_id"); - table.updateItem(result); + 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)); - SimpleBeanWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); - assertThat(updated).isNotNull(); - assertThat(updated.getId()).isEqualTo("updated_id"); + 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 beanSchema_nestedRecord_writeAlways_updatesValues() { - TableSchema schema = BeanTableSchema.create(NestedBeanWithList.class); - NestedBeanWithList initial = new NestedBeanWithList() + public void staticSchema_nestedRecord_updateBehavior_isRespected() { + NestedStaticRecord item = new NestedStaticRecord() .setId("1") - .setAttr("attr_level1") - .setLevel2(new UpdateBehaviorTestModels.NestedBeanChild().setAttr("attr_level2")); - initial.setId("initial_id"); - DynamoDbTable table = createAndPut("nested-bean-write-always", schema, initial); - - NestedBeanWithList result = table.getItem(r -> r.key(k -> k.partitionValue("initial_id"))); - assertThat(result.getId()).isEqualTo("initial_id"); + .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"); - // update id - should change (WRITE_ALWAYS is default) - result.setId("updated_id"); - table.updateItem(result); + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - NestedBeanWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); - assertThat(updated).isNotNull(); - assertThat(updated.getId()).isEqualTo("updated_id"); + NestedStaticRecord updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - table.deleteTable(); - } + // 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 - @Test - public void immutableSchema_simpleRecord_writeAlways_updatesValues() { - TableSchema schema = ImmutableTableSchema.create(SimpleImmutableRecordWithList.class); - SimpleImmutableRecordWithList initial = - SimpleImmutableRecordWithList - .builder() - .id("1") - .attr("attr_level1") - .childList(Arrays.asList( - UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child1").attr("attr_child1").build(), - UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child2").attr("attr_child2").build())) - .build(); - - DynamoDbTable table = createAndPut("simple-immutable-write-always", schema, initial); - - SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getId()).isEqualTo("1"); - - // update with new id - should change (WRITE_ALWAYS is default) - SimpleImmutableRecordWithList updated = SimpleImmutableRecordWithList.builder() - .id("updated_id").attr("attr_level1") - .build(); - table.updateItem(updated); - - SimpleImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); - assertThat(afterUpdate).isNotNull(); - assertThat(afterUpdate.getId()).isEqualTo("updated_id"); + // 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_nestedRecord_writeAlways_updatesValues() { - TableSchema schema = ImmutableTableSchema.create(NestedImmutableRecordWithList.class); - NestedImmutableRecordWithList initial = - NestedImmutableRecordWithList - .builder() - .id("1") - .attr("attr_level1") - .level2(UpdateBehaviorTestModels.NestedImmutableChildRecordWithList - .builder().attr("attr_level2").build()) - .build(); - DynamoDbTable table = createAndPut("nested-immutable-write-always", schema, initial); - - NestedImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getId()).isEqualTo("1"); - - // update with new id - should change (WRITE_ALWAYS is default) - NestedImmutableRecordWithList updated = NestedImmutableRecordWithList.builder() - .id("updated_id").attr("attr_level1") - .build(); - table.updateItem(updated); - - NestedImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); - assertThat(afterUpdate).isNotNull(); - assertThat(afterUpdate.getId()).isEqualTo("updated_id"); + 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); - table.deleteTable(); - } + // 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(); - @Test - public void staticSchema_simpleRecord_writeAlways_updatesValues() { - DynamoDbTable table = - createAndPut("simple-static-write-always", - buildStaticSchemaForSimpleRecordWithList(), - new SimpleStaticRecordWithList() - .setId("1") - .setAttr("attr_level1")); + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - SimpleStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getId()).isEqualTo("1"); + // WRITE_ALWAYS fields should be updated + assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // should change - // update id - should change (WRITE_ALWAYS is default) - result.setId("updated_id"); - table.updateItem(result); + // WRITE_IF_NOT_EXISTS fields should NOT be updated (keep original values) + assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // should NOT change - SimpleStaticRecordWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); - assertThat(updated).isNotNull(); - assertThat(updated.getId()).isEqualTo("updated_id"); + // 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 staticSchema_nestedRecord_writeAlways_updatesValues() { - DynamoDbTable table = createAndPut( - "nested-static-write-always", - buildStaticSchemaForNestedRecordWithList(), - new NestedStaticRecordWithList() - .setId("1") - .setAttr("attr_level1") - .setLevel2(new UpdateBehaviorTestModels.NestedStaticChildRecordWithList().setAttr("attr_level2"))); - - NestedStaticRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getId()).isEqualTo("1"); - - // update id - should change (WRITE_ALWAYS is default) - result.setId("updated_id"); - table.updateItem(result); - - NestedStaticRecordWithList updated = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); - assertThat(updated).isNotNull(); - assertThat(updated.getId()).isEqualTo("updated_id"); + 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.deleteTable(); - } + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - @Test - public void staticImmutableSchema_simpleRecord_writeAlways_updatesValues() { - DynamoDbTable table = - createAndPut("simple-static-immutable-write-always", - buildStaticImmutableSchemaForSimpleRecordWithList(), - SimpleImmutableRecordWithList - .builder() - .id("1") - .attr("attr_level1") - .childList(Arrays.asList( - UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child1").attr("attr_child1").build(), - UpdateBehaviorTestModels.SimpleImmutableChild.builder().id("child2").attr("attr_child2").build())) - .build()); - - SimpleImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getId()).isEqualTo("1"); - - // update with new id - should change (WRITE_ALWAYS is default) - SimpleImmutableRecordWithList updated = SimpleImmutableRecordWithList.builder() - .id("updated_id").attr("attr_level1") - .build(); - table.updateItem(updated); - - SimpleImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); - assertThat(afterUpdate).isNotNull(); - assertThat(afterUpdate.getId()).isEqualTo("updated_id"); + NestedImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - table.deleteTable(); - } + // WRITE_ALWAYS fields should be updated + assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // should change - @Test - public void staticImmutableSchema_nestedRecord_writeAlways_updatesValues() { - DynamoDbTable table = - createAndPut("nested-static-immutable-write-always", - buildStaticImmutableSchemaForNestedRecordWithList(), - NestedImmutableRecordWithList.builder() - .id("1") - .attr("attr_level1") - .level2(UpdateBehaviorTestModels.NestedImmutableChildRecordWithList - .builder().attr("attr_level2").build()) - .build()); - - NestedImmutableRecordWithList result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); - assertThat(result.getId()).isEqualTo("1"); - - // update with new id - should change (WRITE_ALWAYS is default) - NestedImmutableRecordWithList updated = NestedImmutableRecordWithList.builder() - .id("updated_id").attr("attr_level1") - .build(); - table.updateItem(updated); - - NestedImmutableRecordWithList afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("updated_id"))); - assertThat(afterUpdate).isNotNull(); - assertThat(afterUpdate.getId()).isEqualTo("updated_id"); + // WRITE_IF_NOT_EXISTS fields should NOT be updated (keep original values) + assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // should NOT change table.deleteTable(); } 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 f246488903ca..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 @@ -3,7 +3,7 @@ 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.BeanWithInvalidNestedAttributeNameChild; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.BeanWithInvalidNestedAttributeName.ChildBeanWithInvalidAttributeName; import com.google.common.collect.ImmutableList; import java.time.Instant; @@ -786,16 +786,16 @@ public void updateBehaviour_onItemWithRootAttributeNameContainingReservedMarker_ public void updateBehaviour_onItemWithNestedAttributeNameContainingReservedMarker_throwsException() { thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("Attribute name 'attr_NESTED_ATTR_UPDATE_' contains reserved marker " + 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"); - BeanWithInvalidNestedAttributeNameChild childBean = new BeanWithInvalidNestedAttributeNameChild(); + ChildBeanWithInvalidAttributeName childBean = new ChildBeanWithInvalidAttributeName(); childBean.setId("2"); - childBean.setAttr_NESTED_ATTR_UPDATE_(INSTANT_1); - record.setNestedChildAttribute(childBean); + 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/UpdateBehaviorTestModels.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java index 5c84edc99755..7b0005525466 100644 --- 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 @@ -19,9 +19,9 @@ 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 java.util.Objects; import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema; @@ -33,8 +33,9 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; /** - * Test models specifically designed for update behavior functionality testing. These models focus on the "attr" attribute - * annotated with @DynamoDbUpdateBehavior annotation and are used by NestedUpdateBehaviorTest. + * 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 { @@ -42,9 +43,10 @@ private UpdateBehaviorTestModels() { } @DynamoDbBean - public static class SimpleBeanWithList { + public static class SimpleBean { private String id; - private String attr; + private String writeAlwaysField; + private String writeOnceField; private List childList; @DynamoDbPartitionKey @@ -52,188 +54,162 @@ public String getId() { return id; } - public SimpleBeanWithList setId(String id) { + public SimpleBean setId(String id) { this.id = id; return this; } - @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) - public String getAttr() { - return attr; + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getWriteAlwaysField() { + return writeAlwaysField; } - public SimpleBeanWithList setAttr(String attr) { - this.attr = attr; + public SimpleBean setWriteAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; return this; } - public List getChildList() { - return childList; + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getWriteOnceField() { + return writeOnceField; } - public SimpleBeanWithList setChildList(List childList) { - this.childList = childList; + public SimpleBean setWriteOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; return this; } - @Override - public final boolean equals(Object o) { - if (!(o instanceof SimpleBeanWithList)) { - return false; - } - - SimpleBeanWithList that = (SimpleBeanWithList) o; - return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) && Objects.equals(childList, that.childList); + public List getChildList() { + return childList == null ? null : Collections.unmodifiableList(childList); } - @Override - public int hashCode() { - int result = Objects.hashCode(id); - result = 31 * result + Objects.hashCode(attr); - result = 31 * result + Objects.hashCode(childList); - return result; + public SimpleBean setChildList(List childList) { + this.childList = childList == null ? null : Collections.unmodifiableList(childList); + return this; } } @DynamoDbBean - public static class SimpleBeanChild { + public static class NestedBean { private String id; - private String attr; + private String writeAlwaysField; + private String writeOnceField; + private NestedBeanChild child; @DynamoDbPartitionKey public String getId() { return id; } - public SimpleBeanChild setId(String 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 getAttr() { - return attr; + public String getWriteOnceField() { + return writeOnceField; } - public SimpleBeanChild setAttr(String attr) { - this.attr = attr; + public NestedBean setWriteOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; return this; } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - SimpleBeanChild that = (SimpleBeanChild) o; - return Objects.equals(id, that.id) && Objects.equals(attr, that.attr); + public NestedBeanChild getChild() { + return child; } - @Override - public int hashCode() { - return Objects.hash(id, attr); + public NestedBean setChild(NestedBeanChild child) { + this.child = child; + return this; } } @DynamoDbBean - public static class NestedBeanWithList { + public static class SimpleBeanChild { private String id; - private String attr; - private NestedBeanChild level2; + private String childAlwaysUpdate; + private String childWriteOnce; @DynamoDbPartitionKey public String getId() { return id; } - public NestedBeanWithList setId(String id) { + public SimpleBeanChild setId(String id) { this.id = id; return this; } - @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) - public String getAttr() { - return attr; + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; } - public NestedBeanWithList setAttr(String attr) { - this.attr = attr; + public SimpleBeanChild setChildAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; return this; } - public NestedBeanChild getLevel2() { - return level2; + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getChildWriteOnce() { + return childWriteOnce; } - public NestedBeanWithList setLevel2(NestedBeanChild level2) { - this.level2 = level2; + public SimpleBeanChild setChildWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; return this; } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - NestedBeanWithList that = (NestedBeanWithList) o; - return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) - && Objects.equals(level2, that.level2); - } - - @Override - public int hashCode() { - return Objects.hash(id, attr, level2); - } } @DynamoDbBean public static class NestedBeanChild { - private String attr; + private String childAlwaysUpdate; + private String childWriteOnce; - @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) - public String getAttr() { - return attr; + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; } - public NestedBeanChild setAttr(String attr) { - this.attr = attr; + public NestedBeanChild setChildAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; return this; } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - NestedBeanChild that = (NestedBeanChild) o; - return Objects.equals(attr, that.attr); + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getChildWriteOnce() { + return childWriteOnce; } - @Override - public int hashCode() { - return Objects.hash(attr - ); + public NestedBeanChild setChildWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; + return this; } } - @DynamoDbImmutable(builder = SimpleImmutableRecordWithList.Builder.class) - public static final class SimpleImmutableRecordWithList { + @DynamoDbImmutable(builder = SimpleImmutableRecord.Builder.class) + public static final class SimpleImmutableRecord { private final String id; - private final String attr; + private final String writeAlwaysField; + private final String writeOnceField; private final List childList; - private SimpleImmutableRecordWithList(Builder b) { + private SimpleImmutableRecord(Builder b) { this.id = b.id; - this.attr = b.attr; + this.writeAlwaysField = b.writeAlwaysField; + this.writeOnceField = b.writeOnceField; this.childList = b.childList; } @@ -242,9 +218,14 @@ public String getId() { return id; } + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getWriteAlwaysField() { + return writeAlwaysField; + } + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) - public String getAttr() { - return attr; + public String getWriteOnceField() { + return writeOnceField; } public List getChildList() { @@ -257,7 +238,8 @@ public static Builder builder() { public static final class Builder { private String id; - private String attr; + private String writeAlwaysField; + private String writeOnceField; private List childList; public Builder id(String id) { @@ -265,48 +247,39 @@ public Builder id(String id) { return this; } - public Builder attr(String attr) { - this.attr = attr; + public Builder writeAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; return this; } - public Builder childList(List childList) { - this.childList = childList; + public Builder writeOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; return this; } - public SimpleImmutableRecordWithList build() { - return new SimpleImmutableRecordWithList(this); + public Builder childList(List childList) { + this.childList = childList == null ? null : Collections.unmodifiableList(childList); + return this; } - } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; + public SimpleImmutableRecord build() { + return new SimpleImmutableRecord(this); } - if (o == null || getClass() != o.getClass()) { - return false; - } - SimpleImmutableRecordWithList that = (SimpleImmutableRecordWithList) o; - return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) - && Objects.equals(childList, that.childList); - } - - @Override - public int hashCode() { - return Objects.hash(id, attr, childList); } } - @DynamoDbImmutable(builder = SimpleImmutableChild.Builder.class) - public static final class SimpleImmutableChild { + @DynamoDbImmutable(builder = NestedImmutableRecord.Builder.class) + public static final class NestedImmutableRecord { private final String id; - private final String attr; + private final String writeAlwaysField; + private final String writeOnceField; + private final NestedImmutableChild child; - private SimpleImmutableChild(Builder b) { + private NestedImmutableRecord(Builder b) { this.id = b.id; - this.attr = b.attr; + this.writeAlwaysField = b.writeAlwaysField; + this.writeOnceField = b.writeOnceField; + this.child = b.child; } @DynamoDbPartitionKey @@ -314,9 +287,18 @@ public String getId() { return id; } + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getWriteAlwaysField() { + return writeAlwaysField; + } + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) - public String getAttr() { - return attr; + public String getWriteOnceField() { + return writeOnceField; + } + + public NestedImmutableChild getChild() { + return child; } public static Builder builder() { @@ -325,52 +307,46 @@ public static Builder builder() { public static final class Builder { private String id; - private String attr; + private String writeAlwaysField; + private String writeOnceField; + private NestedImmutableChild child; public Builder id(String id) { this.id = id; return this; } - public Builder attr(String attr) { - this.attr = attr; + public Builder writeAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; return this; } - public SimpleImmutableChild build() { - return new SimpleImmutableChild(this); + public Builder writeOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; + return this; } - } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; + public Builder child(NestedImmutableChild child) { + this.child = child; + return this; } - SimpleImmutableChild that = (SimpleImmutableChild) o; - return Objects.equals(id, that.id) && Objects.equals(attr, that.attr); - } - @Override - public int hashCode() { - return Objects.hash(id, attr); + public NestedImmutableRecord build() { + return new NestedImmutableRecord(this); + } } } - @DynamoDbImmutable(builder = NestedImmutableRecordWithList.Builder.class) - public static final class NestedImmutableRecordWithList { + @DynamoDbImmutable(builder = SimpleImmutableChild.Builder.class) + public static final class SimpleImmutableChild { private final String id; - private final String attr; - private final NestedImmutableChildRecordWithList level2; + private final String childAlwaysUpdate; + private final String childWriteOnce; - private NestedImmutableRecordWithList(Builder b) { + private SimpleImmutableChild(Builder b) { this.id = b.id; - this.attr = b.attr; - - this.level2 = b.level2; + this.childAlwaysUpdate = b.childAlwaysUpdate; + this.childWriteOnce = b.childWriteOnce; } @DynamoDbPartitionKey @@ -378,13 +354,14 @@ public String getId() { return id; } - @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) - public String getAttr() { - return attr; + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; } - public NestedImmutableChildRecordWithList getLevel2() { - return level2; + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getChildWriteOnce() { + return childWriteOnce; } public static Builder builder() { @@ -393,60 +370,48 @@ public static Builder builder() { public static final class Builder { private String id; - private String attr; - private NestedImmutableChildRecordWithList level2; + private String childAlwaysUpdate; + private String childWriteOnce; public Builder id(String id) { this.id = id; return this; } - public Builder attr(String attr) { - this.attr = attr; + public Builder childAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; return this; } - public Builder level2(NestedImmutableChildRecordWithList level2) { - this.level2 = level2; + public Builder childWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; return this; } - public NestedImmutableRecordWithList build() { - return new NestedImmutableRecordWithList(this); - } - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; + public SimpleImmutableChild build() { + return new SimpleImmutableChild(this); } - NestedImmutableRecordWithList that = (NestedImmutableRecordWithList) o; - return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) - && Objects.equals(level2, that.level2); - } - - @Override - public int hashCode() { - return Objects.hash(id, attr, level2); } } - @DynamoDbImmutable(builder = NestedImmutableChildRecordWithList.Builder.class) - public static final class NestedImmutableChildRecordWithList { - private final String attr; + @DynamoDbImmutable(builder = NestedImmutableChild.Builder.class) + public static final class NestedImmutableChild { + private final String childAlwaysUpdate; + private final String childWriteOnce; - private NestedImmutableChildRecordWithList(Builder b) { - this.attr = b.attr; + 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 getAttr() { - return attr; + public String getChildWriteOnce() { + return childWriteOnce; } public static Builder builder() { @@ -454,269 +419,354 @@ public static Builder builder() { } public static final class Builder { - private String attr; + private String childAlwaysUpdate; + private String childWriteOnce; - public Builder attr(String attr) { - this.attr = attr; + public Builder childAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; return this; } - public NestedImmutableChildRecordWithList build() { - return new NestedImmutableChildRecordWithList(this); + public Builder childWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; + return this; } - } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; + public NestedImmutableChild build() { + return new NestedImmutableChild(this); } - NestedImmutableChildRecordWithList that = (NestedImmutableChildRecordWithList) o; - return Objects.equals(attr, that.attr); - } - - @Override - public int hashCode() { - return Objects.hash(attr); } } - public static class SimpleStaticRecordWithList { + public static class SimpleStaticRecord { private String id; - private String attr; + private String writeAlwaysField; + private String writeOnceField; public String getId() { return id; } - public SimpleStaticRecordWithList setId(String id) { + public SimpleStaticRecord setId(String id) { this.id = id; return this; } - public String getAttr() { - return attr; + public String getWriteAlwaysField() { + return writeAlwaysField; } - public SimpleStaticRecordWithList setAttr(String attr) { - this.attr = attr; + public SimpleStaticRecord setWriteAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; return this; } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - SimpleStaticRecordWithList that = (SimpleStaticRecordWithList) o; - return Objects.equals(id, that.id) && Objects.equals(attr, that.attr); + public String getWriteOnceField() { + return writeOnceField; } - @Override - public int hashCode() { - return Objects.hash(id, attr); + public SimpleStaticRecord setWriteOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; + return this; } } - public static class NestedStaticRecordWithList { + public static class NestedStaticRecord { private String id; - private String attr; - private NestedStaticChildRecordWithList level2; + private String writeAlwaysField; + private String writeOnceField; + private NestedStaticChildRecord child; public String getId() { return id; } - public NestedStaticRecordWithList setId(String id) { + public NestedStaticRecord setId(String id) { this.id = id; return this; } - public String getAttr() { - return attr; + public String getWriteAlwaysField() { + return writeAlwaysField; } - public NestedStaticRecordWithList setAttr(String attr) { - this.attr = attr; + public NestedStaticRecord setWriteAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; return this; } - public NestedStaticChildRecordWithList getLevel2() { - return level2; + public String getWriteOnceField() { + return writeOnceField; } - public NestedStaticRecordWithList setLevel2(NestedStaticChildRecordWithList level2) { - this.level2 = level2; + public NestedStaticRecord setWriteOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; return this; } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - NestedStaticRecordWithList that = (NestedStaticRecordWithList) o; - return Objects.equals(id, that.id) && Objects.equals(attr, that.attr) - && Objects.equals(level2, that.level2); + public NestedStaticChildRecord getChild() { + return child; } - @Override - public int hashCode() { - return Objects.hash(id, attr, level2); + public NestedStaticRecord setChild(NestedStaticChildRecord child) { + this.child = child; + return this; } } - public static class NestedStaticChildRecordWithList { - private String attr; + public static class NestedStaticChildRecord { + private String childAlwaysUpdate; + private String childWriteOnce; - public String getAttr() { - return attr; + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; } - public NestedStaticChildRecordWithList setAttr(String attr) { - this.attr = attr; + public NestedStaticChildRecord setChildAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; return this; } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - NestedStaticChildRecordWithList that = (NestedStaticChildRecordWithList) o; - return Objects.equals(attr, that.attr); + public String getChildWriteOnce() { + return childWriteOnce; } - @Override - public int hashCode() { - return Objects.hash(attr); + public NestedStaticChildRecord setChildWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; + return this; } } - public static TableSchema buildStaticSchemaForSimpleRecordWithList() { - return StaticTableSchema.builder(SimpleStaticRecordWithList.class) - .newItemSupplier(SimpleStaticRecordWithList::new) - .addAttribute(String.class, a -> a.name("id") - .getter(SimpleStaticRecordWithList::getId) - .setter(SimpleStaticRecordWithList::setId) - .tags(primaryPartitionKey())) - .addAttribute(String.class, a -> a.name("attr") - .getter(SimpleStaticRecordWithList::getAttr) - .setter(SimpleStaticRecordWithList::setAttr) - .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) - .build(); + 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 buildStaticSchemaForNestedRecordWithList() { - TableSchema level2Schema = - StaticTableSchema.builder(NestedStaticChildRecordWithList.class) - .newItemSupplier(NestedStaticChildRecordWithList::new) - .addAttribute(String.class, a -> a.name("attr") - .getter(NestedStaticChildRecordWithList::getAttr) - .setter(NestedStaticChildRecordWithList::setAttr) - .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) - .build(); - - return StaticTableSchema.builder(NestedStaticRecordWithList.class) - .newItemSupplier(NestedStaticRecordWithList::new) - .addAttribute(String.class, a -> a.name("id") - .getter(NestedStaticRecordWithList::getId) - .setter(NestedStaticRecordWithList::setId) - .tags(primaryPartitionKey())) - .addAttribute(String.class, a -> a.name("attr") - .getter(NestedStaticRecordWithList::getAttr) - .setter(NestedStaticRecordWithList::setAttr) - .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) - .addAttribute(EnhancedType.documentOf(NestedStaticChildRecordWithList.class, level2Schema), - a -> a.name("level2") - .getter(NestedStaticRecordWithList::getLevel2) - .setter(NestedStaticRecordWithList::setLevel2)) - .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 buildStaticImmutableSchemaForSimpleRecordWithList() { - TableSchema childSchema = - StaticImmutableTableSchema.builder(SimpleImmutableChild.class, - SimpleImmutableChild.Builder.class) - .newItemBuilder(SimpleImmutableChild::builder, - SimpleImmutableChild.Builder::build) - .addAttribute(String.class, a -> a.name("id") - .getter(SimpleImmutableChild::getId) - .setter(SimpleImmutableChild.Builder::id) - .tags(primaryPartitionKey())) - .addAttribute(String.class, a -> a.name("attr") - .getter(SimpleImmutableChild::getAttr) - .setter(SimpleImmutableChild.Builder::attr) - .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) - .build(); - - return StaticImmutableTableSchema.builder(SimpleImmutableRecordWithList.class, - SimpleImmutableRecordWithList.Builder.class) - .newItemBuilder(SimpleImmutableRecordWithList::builder, - SimpleImmutableRecordWithList.Builder::build) - .addAttribute(String.class, a -> a.name("id") - .getter(SimpleImmutableRecordWithList::getId) - .setter(SimpleImmutableRecordWithList.Builder::id) - .tags(primaryPartitionKey())) - .addAttribute(String.class, a -> a.name("attr") - .getter(SimpleImmutableRecordWithList::getAttr) - .setter(SimpleImmutableRecordWithList.Builder::attr) - .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) - .addAttribute(EnhancedType.listOf(EnhancedType.documentOf(SimpleImmutableChild.class, - childSchema)), - a -> a.name("childList") - .getter(SimpleImmutableRecordWithList::getChildList) - .setter(SimpleImmutableRecordWithList.Builder::childList)) - .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 buildStaticImmutableSchemaForNestedRecordWithList() { - TableSchema level2Schema = - StaticImmutableTableSchema.builder(NestedImmutableChildRecordWithList.class, - NestedImmutableChildRecordWithList.Builder.class) - .newItemBuilder(NestedImmutableChildRecordWithList::builder, - NestedImmutableChildRecordWithList.Builder::build) - .addAttribute(String.class, a -> a.name("attr") - .getter(NestedImmutableChildRecordWithList::getAttr) - .setter(NestedImmutableChildRecordWithList.Builder::attr) - .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) - .build(); - - return StaticImmutableTableSchema.builder(NestedImmutableRecordWithList.class, - NestedImmutableRecordWithList.Builder.class) - .newItemBuilder(NestedImmutableRecordWithList::builder, - NestedImmutableRecordWithList.Builder::build) - .addAttribute(String.class, a -> a.name("id") - .getter(NestedImmutableRecordWithList::getId) - .setter(NestedImmutableRecordWithList.Builder::id) - .tags(primaryPartitionKey())) - .addAttribute(String.class, a -> a.name("attr") - .getter(NestedImmutableRecordWithList::getAttr) - .setter(NestedImmutableRecordWithList.Builder::attr) - .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) - .addAttribute(EnhancedType.documentOf(NestedImmutableChildRecordWithList.class, - level2Schema), - a -> a.name("level2") - .getter(NestedImmutableRecordWithList::getLevel2) - .setter(NestedImmutableRecordWithList.Builder::level2)) - .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(); } /** - * 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. + * 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 { @@ -745,13 +795,13 @@ public BeanWithInvalidRootAttributeName setAttr_NESTED_ATTR_UPDATE_(Instant attr } /** - * 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. + * Test model with an invalid nested attribute name containing the reserved '_NESTED_ATTR_UPDATE_' pattern. Used to test + * validation of nested attribute names that conflict with internal DynamoDB Enhanced Client conventions. */ @DynamoDbBean public static class BeanWithInvalidNestedAttributeName { private String id; - private BeanWithInvalidNestedAttributeNameChild nestedChildAttribute; + private ChildBeanWithInvalidAttributeName child; @DynamoDbPartitionKey public String getId() { @@ -763,20 +813,18 @@ public BeanWithInvalidNestedAttributeName setId(String id) { return this; } - public BeanWithInvalidNestedAttributeNameChild getNestedChildAttribute() { - return nestedChildAttribute; + public ChildBeanWithInvalidAttributeName getChild() { + return child; } - public BeanWithInvalidNestedAttributeName setNestedChildAttribute( - BeanWithInvalidNestedAttributeNameChild nestedChildAttribute) { - this.nestedChildAttribute = nestedChildAttribute; + public BeanWithInvalidNestedAttributeName setChild(ChildBeanWithInvalidAttributeName child) { + this.child = child; return this; } @DynamoDbBean - public static class BeanWithInvalidNestedAttributeNameChild { + public static class ChildBeanWithInvalidAttributeName { private String id; - private BeanWithInvalidNestedAttributeNameChild nestedChildAttribute; private Instant childAttr_NESTED_ATTR_UPDATE_; @DynamoDbPartitionKey @@ -784,28 +832,18 @@ public String getId() { return id; } - public BeanWithInvalidNestedAttributeNameChild setId(String id) { + public ChildBeanWithInvalidAttributeName setId(String id) { this.id = id; return this; } - public BeanWithInvalidNestedAttributeNameChild getNestedChildAttribute() { - return nestedChildAttribute; - } - - public BeanWithInvalidNestedAttributeNameChild setNestedChildAttribute( - BeanWithInvalidNestedAttributeNameChild nestedChildAttribute) { - this.nestedChildAttribute = nestedChildAttribute; - return this; - } - @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) - public Instant getAttr_NESTED_ATTR_UPDATE_() { + public Instant getChildAttr_NESTED_ATTR_UPDATE_() { return childAttr_NESTED_ATTR_UPDATE_; } - public BeanWithInvalidNestedAttributeNameChild setAttr_NESTED_ATTR_UPDATE_(Instant attr_NESTED_ATTR_UPDATE_) { - this.childAttr_NESTED_ATTR_UPDATE_ = attr_NESTED_ATTR_UPDATE_; + public ChildBeanWithInvalidAttributeName setChildAttr_NESTED_ATTR_UPDATE_(Instant childAttr_NESTED_ATTR_UPDATE_) { + this.childAttr_NESTED_ATTR_UPDATE_ = childAttr_NESTED_ATTR_UPDATE_; return this; } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java index 6e3bbdbdc9ad..30a6e142eedf 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java @@ -16,21 +16,82 @@ package software.amazon.awssdk.enhanced.dynamodb.internal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; -import org.junit.jupiter.api.Test; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +@RunWith(MockitoJUnitRunner.class) public class EnhancedClientUtilsTest { private static final AttributeValue PARTITION_VALUE = AttributeValue.builder().s("id123").build(); private static final AttributeValue SORT_VALUE = AttributeValue.builder().s("sort123").build(); + @Mock + private TableSchema mockSchema; + + @Mock + private AttributeConverter mockConverter; + + @Mock + private EnhancedType mockEnhancedType; + + @Mock + private EnhancedType mockParameterType; + + @Mock + private TableSchema mockNestedSchema; + + @Test + public void hasMap_forNotNullAttributeValueWithMap_returnsTrue() { + AttributeValue nullValue = AttributeValue.builder().nul(false).m(new HashMap<>()).build(); + + boolean result = EnhancedClientUtils.hasMap(nullValue); + + assertThat(result).isTrue(); + } + + @Test + public void hasMap_forNullAttributeValue_returnsFalse() { + AttributeValue nullValue = AttributeValue.builder().nul(true).build(); + + boolean result = EnhancedClientUtils.hasMap(nullValue); + + assertThat(result).isFalse(); + } + + @Test + public void hasMap_forNotNullAttributeValueWithoutMap_returnsFalse() { + AttributeValue nullValue = AttributeValue.builder().nul(false).build(); + + boolean result = EnhancedClientUtils.hasMap(nullValue); + + assertThat(result).isFalse(); + } + + @Test + public void hasMap_forAttributeValueNull_returnsFalse() { + + boolean result = EnhancedClientUtils.hasMap(null); + + assertThat(result).isFalse(); + } + @Test public void createKeyFromMap_partitionOnly() { Map itemMap = new HashMap<>(); @@ -64,4 +125,275 @@ public void cleanAttributeName_cleansSpecialCharacters() { assertThat(result).isEqualTo("a_b_c_d_e_f_g_h_i_j_k_l_m_n_o_p_q_r_s_t_u"); } + + @Test + public void getNestedSchema_withNullConverter_returnsEmpty() { + when(mockSchema.converterForAttribute("nonExistentAttribute")).thenReturn(null); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "nonExistentAttribute"); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withNullParentSchema_throwsIllegalArgumentException() { + assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(null, "attributeName")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Parent schema cannot be null"); + } + + @Test + public void getNestedSchema_withNullAttributeName_throwsIllegalArgumentException() { + assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(mockSchema, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Attribute name cannot be null or empty"); + } + + @Test + public void getNestedSchema_withEmptyAttributeName_throwsIllegalArgumentException() { + assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(mockSchema, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Attribute name cannot be null or empty"); + } + + @Test + public void getNestedSchema_withWhitespaceAttributeName_doesNotThrow() { + when(mockSchema.converterForAttribute(" ")).thenReturn(null); + + Optional> result = EnhancedClientUtils.getNestedSchema(mockSchema, " "); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withNullEnhancedType_returnsEmpty() { + when(mockSchema.converterForAttribute("attributeWithNullType")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(null); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "attributeWithNullType"); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withParameterizedType_extractsFirstParameter() { + List> parameters = Collections.singletonList(mockParameterType); + when(mockSchema.converterForAttribute("listAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(parameters); + when(mockParameterType.tableSchema()).thenReturn(Optional.of(mockNestedSchema)); + + Optional> result = EnhancedClientUtils.getNestedSchema(mockSchema, "listAttribute"); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(mockNestedSchema); + } + + @Test + public void getNestedSchema_withEmptyParameters_usesOriginalType() { + when(mockSchema.converterForAttribute("simpleAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(Collections.emptyList()); + when(mockEnhancedType.tableSchema()).thenReturn(Optional.of(mockNestedSchema)); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "simpleAttribute"); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(mockNestedSchema); + } + + @Test + public void getNestedSchema_withNullParameters_usesOriginalType() { + when(mockSchema.converterForAttribute("simpleAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(null); + when(mockEnhancedType.tableSchema()).thenReturn(Optional.of(mockNestedSchema)); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "simpleAttribute"); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(mockNestedSchema); + } + + @Test + public void getNestedSchema_withNoTableSchema_returnsEmpty() { + when(mockSchema.converterForAttribute("attributeWithoutSchema")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(Collections.emptyList()); + when(mockEnhancedType.tableSchema()).thenReturn(Optional.empty()); + + Optional> result = EnhancedClientUtils.getNestedSchema(mockSchema, "attributeWithoutSchema"); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withParameterizedTypeNoTableSchema_returnsEmpty() { + List> parameters = Collections.singletonList(mockParameterType); + when(mockSchema.converterForAttribute("listAttributeNoSchema")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(parameters); + when(mockParameterType.tableSchema()).thenReturn(Optional.empty()); + + Optional> result = + EnhancedClientUtils.getNestedSchema(mockSchema, "listAttributeNoSchema"); + + assertThat(result).isEmpty(); + } + + @Test + public void getNestedSchema_withValidInputs_returnsNestedSchema() { + when(mockSchema.converterForAttribute("validAttribute")).thenReturn(mockConverter); + when(mockConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(Collections.emptyList()); + when(mockEnhancedType.tableSchema()).thenReturn(Optional.of(mockNestedSchema)); + + Optional> result = EnhancedClientUtils.getNestedSchema(mockSchema, "validAttribute"); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(mockNestedSchema); + } + + @Test + public void keyRef_withSimpleKey_returnsFormattedKey() { + String result = EnhancedClientUtils.keyRef("simpleKey"); + + assertThat(result).isEqualTo("#AMZN_MAPPED_simpleKey"); + } + + @Test + public void keyRef_withSpecialCharacters_cleansAndFormatsKey() { + String result = EnhancedClientUtils.keyRef("key*with.special-chars"); + + assertThat(result).isEqualTo("#AMZN_MAPPED_key_with_special_chars"); + } + + @Test + public void keyRef_withNestedKey_handlesNestedDelimiter() { + String nestedKey = "parent_NESTED_ATTR_UPDATE_child"; + String result = EnhancedClientUtils.keyRef(nestedKey); + + assertThat(result).contains("#AMZN_MAPPED_"); + assertThat(result).contains("parent"); + assertThat(result).contains("child"); + } + + @Test + public void valueRef_withSimpleValue_returnsFormattedValue() { + String result = EnhancedClientUtils.valueRef("simpleValue"); + + assertThat(result).isEqualTo(":AMZN_MAPPED_simpleValue"); + } + + @Test + public void valueRef_withSpecialCharacters_cleansAndFormatsValue() { + String result = EnhancedClientUtils.valueRef("value*with.special-chars"); + + assertThat(result).isEqualTo(":AMZN_MAPPED_value_with_special_chars"); + } + + @Test + public void valueRef_withNestedValue_handlesNestedDelimiter() { + String nestedValue = "parent_NESTED_ATTR_UPDATE_child"; + String result = EnhancedClientUtils.valueRef(nestedValue); + + assertThat(result).startsWith(":AMZN_MAPPED_"); + assertThat(result).contains("parent"); + assertThat(result).contains("child"); + } + + @Test + public void cleanAttributeName_withNoSpecialCharacters_returnsOriginal() { + String original = "normalAttributeName123"; + String result = EnhancedClientUtils.cleanAttributeName(original); + + assertThat(result).isSameAs(original); // Should return same instance when no changes needed + } + + @Test + public void isNullAttributeValue_withNullAttributeValue_returnsTrue() { + AttributeValue nullValue = AttributeValue.builder().nul(true).build(); + + boolean result = EnhancedClientUtils.isNullAttributeValue(nullValue); + + assertThat(result).isTrue(); + } + + @Test + public void isNullAttributeValue_withNonNullAttributeValue_returnsFalse() { + AttributeValue stringValue = AttributeValue.builder().s("test").build(); + + boolean result = EnhancedClientUtils.isNullAttributeValue(stringValue); + + assertThat(result).isFalse(); + } + + @Test + public void isNullAttributeValue_withFalseNullValue_returnsFalse() { + AttributeValue falseNullValue = AttributeValue.builder().nul(false).build(); + + boolean result = EnhancedClientUtils.isNullAttributeValue(falseNullValue); + + assertThat(result).isFalse(); + } + + @Test + public void createKeyFromItem_withPartitionKeyOnly_createsCorrectKey() { + FakeItem item = new FakeItem(); + item.setId("test-id"); + + Key result = EnhancedClientUtils.createKeyFromItem(item, FakeItem.getTableSchema(), + TableMetadata.primaryIndexName()); + + assertThat(result.partitionKeyValue()).isEqualTo(AttributeValue.builder().s("test-id").build()); + assertThat(result.sortKeyValue()).isEmpty(); + } + + @Test + public void createKeyFromItem_withPartitionAndSortKey_createsCorrectKey() { + FakeItemWithSort item = new FakeItemWithSort(); + item.setId("test-id"); + item.setSort("test-sort"); + + Key result = EnhancedClientUtils.createKeyFromItem(item, FakeItemWithSort.getTableSchema(), + TableMetadata.primaryIndexName()); + + assertThat(result.partitionKeyValue()).isEqualTo(AttributeValue.builder().s("test-id").build()); + assertThat(result.sortKeyValue()).isPresent(); + assertThat(result.sortKeyValue().get()).isEqualTo(AttributeValue.builder().s("test-sort").build()); + } + + @Test + public void readAndTransformSingleItem_withNullItemMap_returnsNull() { + Object result = EnhancedClientUtils.readAndTransformSingleItem(null, mockSchema, null, null); + + assertThat(result).isNull(); + } + + @Test + public void readAndTransformSingleItem_withEmptyItemMap_returnsNull() { + Map emptyMap = Collections.emptyMap(); + + Object result = EnhancedClientUtils.readAndTransformSingleItem(emptyMap, mockSchema, null, null); + + assertThat(result).isNull(); + } + + @Test + public void getItemsFromSupplier_withNullList_returnsNull() { + List result = EnhancedClientUtils.getItemsFromSupplier(null); + + assertThat(result).isNull(); + } + + @Test + public void getItemsFromSupplier_withEmptyList_returnsNull() { + List result = EnhancedClientUtils.getItemsFromSupplier(Collections.emptyList()); + + assertThat(result).isNull(); + } } \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java new file mode 100644 index 000000000000..6a91a77a287f --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java @@ -0,0 +1,389 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.testbeans.NestedBean; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +@RunWith(MockitoJUnitRunner.class) +public class NestedRecordUtilsTest { + + private static final String NESTED_OBJECT_UPDATE = "_NESTED_ATTR_UPDATE_"; + private static final Pattern PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); + private static final Pattern NESTED_ATTR_UPDATE_ = Pattern.compile("_NESTED_ATTR_UPDATE_"); + + @Mock + private TableSchema objectSchema; + + @Mock + private TableSchema> listSchema; + + @Mock + private AttributeConverter objectConverter; + + @Mock + private AttributeConverter> listConverter; + + @Mock + private EnhancedType objectType; + + @Mock + private EnhancedType> listType; + + @Test + public void getTableSchemaForListElement_withNullConverter_throwsIllegalArgumentException() { + when(objectSchema.converterForAttribute("nonExistentAttribute")).thenReturn(null); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(objectSchema, "nonExistentAttribute")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No converter found for attribute: nonExistentAttribute"); + } + + @Test + public void getTableSchemaForListElement_withEmptyRawClassParameters_throwsIllegalArgumentException() { + when(objectSchema.converterForAttribute("emptyParamsAttribute")).thenReturn(objectConverter); + when(objectConverter.type()).thenReturn(objectType); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(objectSchema, "emptyParamsAttribute")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No type parameters found for list attribute: emptyParamsAttribute"); + } + + @Test + public void getTableSchemaForListElement_withNullRawClassParameters_throwsIllegalArgumentException() { + when(objectSchema.converterForAttribute("nullParamsAttribute")).thenReturn(objectConverter); + when(objectConverter.type()).thenReturn(objectType); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(objectSchema, "nullParamsAttribute")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No type parameters found for list attribute: nullParamsAttribute"); + } + + @Test + public void getTableSchemaForListElement_withListConverter_returnsCorrectTableSchema() { + List> parameters = Collections.singletonList(listType); + when(listSchema.converterForAttribute("listAttribute")).thenReturn(listConverter); + when(listConverter.type()).thenReturn(listType); + when(listType.rawClass()).thenReturn((Class) NestedBean.class); + when(listType.rawClassParameters()).thenReturn(parameters); + + TableSchema result = NestedRecordUtils.getTableSchemaForListElement(listSchema, "listAttribute"); + ; + assertThat(result).isInstanceOf(BeanTableSchema.class); + } + + @Test + public void getTableSchemaForListElement_withDeepNestedPath_returnsCorrectSchema() { + String nestedKey = "nestedItem" + NESTED_OBJECT_UPDATE + "tags"; + String[] parts = PATTERN.split(nestedKey); + + assertThat(parts).hasSize(2); + assertThat(parts[0]).isEqualTo("nestedItem"); + assertThat(parts[1]).isEqualTo("tags"); + + String deepNestedKey = String.join(NESTED_OBJECT_UPDATE, "parent", "child", "grandchild"); + String[] deepParts = PATTERN.split(deepNestedKey); + + assertThat(deepParts).hasSize(3); + assertThat(deepParts[0]).isEqualTo("parent"); + assertThat(deepParts[1]).isEqualTo("child"); + assertThat(deepParts[2]).isEqualTo("grandchild"); + } + + @Test + public void getTableSchemaForListElement_withNestedPathAndMissingSchema_throwsIllegalArgumentException() { + String nestedKey = String.join(NESTED_OBJECT_UPDATE, "parent", "child", "listAttribute"); + + // Mock the parent schema resolution to return empty + when(objectSchema.converterForAttribute("parent")).thenReturn(null); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(objectSchema, nestedKey)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unable to resolve schema for list element at: " + nestedKey); + } + + @Test + public void getTableSchemaForListElement_withStaticNestedSchema_returnsCorrectSchema() { + TableSchema childSchema = mock(TableSchema.class); + AttributeConverter childConverter = mock(AttributeConverter.class); + EnhancedType childType = mock(EnhancedType.class); + + when(objectSchema.converterForAttribute("child")).thenReturn(childConverter); + when(childConverter.type()).thenReturn(childType); + when(childType.tableSchema()).thenReturn(Optional.of(childSchema)); + + TableSchema result = NestedRecordUtils.getTableSchemaForListElement(objectSchema, "child"); + assertThat(result).isEqualTo(childSchema); + } + + @Test + public void getTableSchemaForListElement_withNestedPathAndPresentSchemas_returnsCorrectSchema() { + String nestedKey = String.join(NESTED_OBJECT_UPDATE, "parent", "child", "listAttribute"); + + TableSchema level1Schema = mock(TableSchema.class); + TableSchema level2Schema = mock(TableSchema.class); + TableSchema expectedListElementSchema = mock(TableSchema.class); + + AttributeConverter converter1 = mock(AttributeConverter.class); + AttributeConverter converter2 = mock(AttributeConverter.class); + AttributeConverter converter3 = mock(AttributeConverter.class); + + EnhancedType type1 = mock(EnhancedType.class); + EnhancedType type2 = mock(EnhancedType.class); + EnhancedType type3 = mock(EnhancedType.class); + + // parent -> level1 + when(objectSchema.converterForAttribute("parent")).thenReturn(converter1); + when(converter1.type()).thenReturn(type1); + when(type1.tableSchema()).thenReturn(Optional.of(level1Schema)); + + // level1 -> level2 + when(level1Schema.converterForAttribute("child")).thenReturn(converter2); + when(converter2.type()).thenReturn(type2); + when(type2.tableSchema()).thenReturn(Optional.of(level2Schema)); + + // level2 -> list element schema + when(level2Schema.converterForAttribute("listAttribute")).thenReturn(converter3); + when(converter3.type()).thenReturn(type3); + when(type3.tableSchema()).thenReturn(Optional.of(expectedListElementSchema)); + + TableSchema result = NestedRecordUtils.getTableSchemaForListElement(objectSchema, nestedKey); + assertThat(result).isEqualTo(expectedListElementSchema); + } + + @Test + public void getTableSchemaForListElement_withClassNotFound_throwsIllegalArgumentException() { + String badAttr = "badAttr"; + when(objectSchema.converterForAttribute(badAttr)).thenReturn(objectConverter); + + EnhancedType enhancedType = mock(EnhancedType.class); + EnhancedType paramType = mock(EnhancedType.class); + + when(objectConverter.type()).thenReturn(enhancedType); + when(enhancedType.rawClassParameters()).thenReturn(Collections.singletonList(paramType)); + + when(paramType.tableSchema()).thenReturn(Optional.empty()); + when(paramType.rawClass()).thenReturn((Class) int.class); + + assertThatThrownBy(() -> NestedRecordUtils.getTableSchemaForListElement(objectSchema, badAttr)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Class not found for field name: " + badAttr); + } + + @Test + public void resolveSchemasPerPath_withEmptyAttributeMap_returnsOnlyRootSchema() { + Map emptyAttributes = new HashMap<>(); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(emptyAttributes, objectSchema); + + assertThat(result).hasSize(1); + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(objectSchema); + } + + @Test + public void resolveSchemasPerPath_withFlatAttributes_returnsOnlyRootSchema() { + Map flatAttributes = new HashMap<>(); + flatAttributes.put("id", AttributeValue.builder().s("test-id").build()); + flatAttributes.put("simpleAttribute", AttributeValue.builder().s("test-value").build()); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(flatAttributes, objectSchema); + + assertThat(result).hasSize(1); + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(objectSchema); + } + + @Test + public void resolveSchemasPerPath_withNestedAttributes_returnsCorrectSchemas() { + Map nestedAttributes = new HashMap<>(); + nestedAttributes.put("id", AttributeValue.builder().s("test-id").build()); + nestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "name", + AttributeValue.builder().s("nested-name").build()); + nestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "tags", + AttributeValue.builder().ss("tag1", "tag2").build()); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(nestedAttributes, objectSchema); + + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(objectSchema); + + boolean hasNestedPath = nestedAttributes.keySet().stream() + .anyMatch(key -> key.contains(NESTED_OBJECT_UPDATE)); + assertThat(hasNestedPath).isTrue(); + } + + @Test + public void resolveSchemasPerPath_withMultipleNestedPaths_returnsAllSchemas() { + Map multipleNestedAttributes = new HashMap<>(); + multipleNestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "name", + AttributeValue.builder().s("nested-name").build()); + multipleNestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "tags", + AttributeValue.builder().ss("tag1", "tag2").build()); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(multipleNestedAttributes, objectSchema); + + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(objectSchema); + + String[] parts1 = NESTED_ATTR_UPDATE_.split("nestedItem_NESTED_ATTR_UPDATE_name"); + String[] parts2 = NESTED_ATTR_UPDATE_.split("nestedItem_NESTED_ATTR_UPDATE_tags"); + + assertThat(parts1[0]).isEqualTo("nestedItem"); + assertThat(parts2[0]).isEqualTo("nestedItem"); + assertThat(parts1[0]).isEqualTo(parts2[0]); // Same nested path + } + + @Test + public void resolveSchemasPerPath_withDuplicateNestedPaths_doesNotDuplicateSchemas() { + Map duplicateNestedAttributes = new HashMap<>(); + duplicateNestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "name", + AttributeValue.builder().s("nested-name-1").build()); + duplicateNestedAttributes.put("nestedItem" + NESTED_OBJECT_UPDATE + "tags", + AttributeValue.builder().ss("tag1").build()); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(duplicateNestedAttributes, objectSchema); + + // Assert - Should have root schema + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(objectSchema); + + // Verify both attributes share the same nested path + String path1 = "nestedItem" + NESTED_OBJECT_UPDATE + "name"; + String path2 = "nestedItem" + NESTED_OBJECT_UPDATE + "tags"; + + String[] parts1 = PATTERN.split(path1); + String[] parts2 = PATTERN.split(path2); + + assertThat(parts1[0]).isEqualTo(parts2[0]); // Same parent path + assertThat(parts1[0]).isEqualTo("nestedItem"); + } + + @Test + public void resolveSchemasPerPath_withDeepNestedPaths_buildsCorrectSchemaMap() { + Map deepNestedAttributes = new HashMap<>(); + deepNestedAttributes.put(String.join(NESTED_OBJECT_UPDATE, "level1", "level2", "level3", "attr"), + AttributeValue.builder().s("deep-value").build()); + + TableSchema level1Schema = mock(TableSchema.class); + TableSchema level2Schema = mock(TableSchema.class); + TableSchema level3Schema = mock(TableSchema.class); + + when(objectSchema.converterForAttribute("level1")).thenReturn(objectConverter); + when(objectConverter.type()).thenReturn(objectType); + when(objectType.tableSchema()).thenReturn(Optional.of(level1Schema)); + + when(objectConverter.type()).thenReturn(objectType); + when(objectType.tableSchema()).thenReturn(Optional.of(level2Schema)); + + when(objectConverter.type()).thenReturn(objectType); + when(objectType.tableSchema()).thenReturn(Optional.of(level3Schema)); + + Map> result = NestedRecordUtils.resolveSchemasPerPath(deepNestedAttributes, objectSchema); + + assertThat(result).containsKey(""); + assertThat(result.get("")).isEqualTo(objectSchema); + assertThat(result.size()).isGreaterThan(1); + } + + @Test + public void reconstructCompositeKey_withNullPath_returnsAttributeName() { + String result = NestedRecordUtils.reconstructCompositeKey(null, "attributeName"); + + assertThat(result).isEqualTo("attributeName"); + } + + @Test + public void reconstructCompositeKey_withEmptyPath_returnsAttributeName() { + String result = NestedRecordUtils.reconstructCompositeKey("", "attributeName"); + + assertThat(result).isEqualTo("attributeName"); + } + + @Test + public void reconstructCompositeKey_withNullAttributeName_throwsIllegalArgumentException() { + assertThatThrownBy(() -> NestedRecordUtils.reconstructCompositeKey("parent", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Attribute name cannot be null"); + } + + @Test + public void reconstructCompositeKey_withSimplePath_returnsCompositeKey() { + String result = NestedRecordUtils.reconstructCompositeKey("parent", "attributeName"); + + assertThat(result).isEqualTo("parent" + NESTED_OBJECT_UPDATE + "attributeName"); + } + + @Test + public void reconstructCompositeKey_withDottedPath_returnsCompositeKey() { + String result = NestedRecordUtils.reconstructCompositeKey("parent.child", "attributeName"); + + assertThat(result).isEqualTo(String.join(NESTED_OBJECT_UPDATE, + "parent", "child", "attributeName")); + } + + @Test + public void reconstructCompositeKey_withDeepDottedPath_returnsCompositeKey() { + String result = NestedRecordUtils.reconstructCompositeKey("parent.child.grandchild", "attributeName"); + + assertThat(result).isEqualTo(String.join(NESTED_OBJECT_UPDATE, + "parent", "child", "grandchild", "attributeName")); + } + + @Test + public void reconstructCompositeKey_withMultipleDots_handlesCorrectly() { + String result = NestedRecordUtils.reconstructCompositeKey("a.b.c.d.e", "finalAttribute"); + + String expected = String.join(NESTED_OBJECT_UPDATE, "a", "b", "c", "d", "e", "finalAttribute"); + assertThat(result).isEqualTo(expected); + } + + @Test + public void reconstructCompositeKey_withWhitespaceInPath_preservesWhitespace() { + String result = NestedRecordUtils.reconstructCompositeKey("parent with spaces.child with spaces", "attr"); + + String expected = String.join(NESTED_OBJECT_UPDATE, "parent with spaces", "child with spaces", "attr"); + assertThat(result).isEqualTo(expected); + } + + @Test + public void reconstructCompositeKey_withSpecialCharactersInPath_preservesCharacters() { + String result = NestedRecordUtils.reconstructCompositeKey( + "parent-with-dashes.child_with_underscores", "attr"); + + String expected = String.join(NESTED_OBJECT_UPDATE, "parent-with-dashes", "child_with_underscores", "attr"); + assertThat(result).isEqualTo(expected); + } +} \ No newline at end of file From ae525c9c55bcdd8fdd592f6cd29c419d57158a7f Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Tue, 10 Feb 2026 05:22:22 +0200 Subject: [PATCH 3/4] Added support for @DynamoDbUpdateBehavior on attributes within nested objects --- .../NestedUpdateBehaviorTest.java | 44 ++++ .../models/UpdateBehaviorTestModels.java | 50 +++++ .../update/UpdateExpressionUtilsTest.java | 192 ++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtilsTest.java 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 index 25ac13b6e672..39a0670891ac 100644 --- 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 @@ -16,10 +16,13 @@ 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; @@ -424,6 +427,47 @@ public void staticImmutableSchema_nestedRecord_updateBehavior_isRespected() { 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); 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 index 7b0005525466..a573b1897f0e 100644 --- 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 @@ -592,6 +592,31 @@ public static TableSchema buildStaticSchemaForNestedRecord() .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) @@ -671,6 +696,31 @@ public static TableSchema buildStaticImmutableSchemaForNe .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. */ diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtilsTest.java new file mode 100644 index 000000000000..96ce7a19f4c1 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtilsTest.java @@ -0,0 +1,192 @@ +/* + * 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.update; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.ifNotExists; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.operationExpression; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; +import software.amazon.awssdk.enhanced.dynamodb.update.RemoveAction; +import software.amazon.awssdk.enhanced.dynamodb.update.SetAction; +import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +@RunWith(MockitoJUnitRunner.class) +public class UpdateExpressionUtilsTest { + + @Mock + private TableSchema root; + + @Mock + private TableSchema tableSchema; + + @Mock + private TableMetadata tableMetadata; + + @Mock + private AttributeConverter attributeConverter; + + @Mock + private EnhancedType enhancedType; + + @Test + public void ifNotExists_generatesExpectedExpression() { + String result = ifNotExists("version", "1"); + assertThat(result).isEqualTo("if_not_exists(#AMZN_MAPPED_version, :AMZN_MAPPED_1)"); + } + + @Test + public void setActionsFor_withPresentNestedSchemas_resolvesUpdateBehaviorUsingNestedSchema() { + when(root.converterForAttribute("parent")).thenReturn(attributeConverter); + when(attributeConverter.type()).thenReturn(enhancedType); + when(enhancedType.tableSchema()).thenReturn(Optional.of(tableSchema)); + when(tableSchema.tableMetadata()).thenReturn(tableMetadata); + when(tableMetadata.customMetadataObject(anyString(), eq(UpdateBehavior.class))).thenReturn(Optional.empty()); + + Map attributes = new HashMap<>(); + attributes.put("parent_NESTED_ATTR_UPDATE_child", AttributeValue.builder().s("value").build()); + + List actions = operationExpression(attributes, root, Collections.emptyList()).setActions(); + assertThat(actions).hasSize(1); + SetAction action = actions.get(0); + + // check expression path and value + assertThat(action.path()).isEqualTo("#AMZN_MAPPED_parent.#AMZN_MAPPED_child"); + assertThat(action.value()).isEqualTo(":AMZN_MAPPED_parent_child"); + + // check expression names + assertThat(action.expressionNames()).hasSize(2); + assertThat(action.expressionNames()).containsEntry("#AMZN_MAPPED_child", "child"); + assertThat(action.expressionNames()).containsEntry("#AMZN_MAPPED_parent", "parent"); + + // check expression values + assertThat(action.expressionValues()).hasSize(1); + assertThat(action.expressionValues()).containsEntry(":AMZN_MAPPED_parent_child", AttributeValue.fromS("value")); + } + + @Test + public void operationExpression_writeIfNotExists_usesIfNotExistsFunction() { + when(root.tableMetadata()).thenReturn(tableMetadata); + when(tableMetadata.customMetadataObject(eq("UpdateBehavior:key"), eq(UpdateBehavior.class))) + .thenReturn(Optional.of(UpdateBehavior.WRITE_IF_NOT_EXISTS)); + + Map attributes = new HashMap<>(); + attributes.put("key", AttributeValue.builder().n("1").build()); + + UpdateExpression expression = operationExpression(attributes, root, Collections.emptyList()); + + List setActions = expression.setActions(); + assertThat(setActions).hasSize(1); + SetAction setAction = setActions.get(0); + + // check that the path is correct and the value uses the if_not_exists function + assertThat(setAction.path()).isEqualTo("#AMZN_MAPPED_key"); + assertThat(setAction.value()).isEqualTo("if_not_exists(#AMZN_MAPPED_key, :AMZN_MAPPED_key)"); + + // check that the expression names are correct + assertThat(setAction.expressionNames()).hasSize(1); + assertThat(setAction.expressionNames()).containsEntry("#AMZN_MAPPED_key", "key"); + + // check that the expression values are correct + assertThat(setAction.expressionValues()).hasSize(1); + assertThat(setAction.expressionValues()).containsEntry(":AMZN_MAPPED_key", AttributeValue.fromN("1")); + } + + @Test + public void operationExpression_nonNestedSchema_generatesSetAndRemoveActions() { + when(root.tableMetadata()).thenReturn(tableMetadata); + when(tableMetadata.customMetadataObject(anyString(), eq(UpdateBehavior.class))).thenReturn(Optional.empty()); + + Map attributes = new HashMap<>(); + attributes.put("key1", AttributeValue.builder().s("key1_value").build()); + attributes.put("key2", AttributeValue.builder().nul(true).build()); + + UpdateExpression expression = operationExpression(attributes, root, Collections.emptyList()); + + List setActions = expression.setActions(); + assertThat(setActions).hasSize(1); + SetAction setAction = setActions.get(0); + + // check expression path and value for the set action + assertThat(setAction.path()).isEqualTo("#AMZN_MAPPED_key1"); + assertThat(setAction.value()).isEqualTo(":AMZN_MAPPED_key1"); + + // check that the expression names are correct for the set action + assertThat(setAction.expressionNames()).hasSize(1); + assertThat(setAction.expressionNames()).containsEntry("#AMZN_MAPPED_key1", "key1"); + + // check that the expression values are correct for the set action + assertThat(setAction.expressionValues()).hasSize(1); + assertThat(setAction.expressionValues()).containsEntry(":AMZN_MAPPED_key1", AttributeValue.fromS("key1_value")); + + // check that the remove action is generated for the null value + List removeActions = expression.removeActions(); + assertThat(removeActions).hasSize(1); + RemoveAction removeAction = removeActions.get(0); + assertThat(removeAction.path()).isEqualTo("#AMZN_MAPPED_key2"); + assertThat(removeAction.expressionNames()).hasSize(1); + assertThat(removeAction.expressionNames()).containsEntry("#AMZN_MAPPED_key2", "key2"); + } + + @Test + public void operationExpression_nestedSchema_generatesSeparateSetActions() { + when(root.converterForAttribute("parent")).thenReturn(attributeConverter); + when(attributeConverter.type()).thenReturn(enhancedType); + when(enhancedType.tableSchema()).thenReturn(Optional.of(tableSchema)); + when(tableSchema.tableMetadata()).thenReturn(tableMetadata); + when(tableMetadata.customMetadataObject(anyString(), eq(UpdateBehavior.class))).thenReturn(Optional.empty()); + + Map attributes = new HashMap<>(); + attributes.put("parent_NESTED_ATTR_UPDATE_child", AttributeValue.builder().s("value").build()); + + UpdateExpression expression = operationExpression(attributes, root, Collections.emptyList()); + + List setActions = expression.setActions(); + assertThat(setActions).hasSize(1); + SetAction setAction = setActions.get(0); + + // check expression path and value for the set action + assertThat(setAction.path()).isEqualTo("#AMZN_MAPPED_parent.#AMZN_MAPPED_child"); + assertThat(setAction.value()).isEqualTo(":AMZN_MAPPED_parent_child"); + + // check that the expression names are correct for the set action + assertThat(setAction.expressionNames()).hasSize(2); + assertThat(setAction.expressionNames()).containsEntry("#AMZN_MAPPED_parent", "parent"); + assertThat(setAction.expressionNames()).containsEntry("#AMZN_MAPPED_child", "child"); + + // check that the expression values are correct for the set action + assertThat(setAction.expressionValues()).hasSize(1); + assertThat(setAction.expressionValues()).containsEntry(":AMZN_MAPPED_parent_child", AttributeValue.fromS("value")); + + } +} From 919e096d1c84b42a78ec0d772a4802b03d4e11f1 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Tue, 10 Feb 2026 05:51:45 +0200 Subject: [PATCH 4/4] Added support for @DynamoDbUpdateBehavior on attributes within nested objects --- .../AutoGeneratedTimestampRecordExtension.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 07794d67450e..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 @@ -178,8 +178,13 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex TableSchema elementListSchema = getTableSchemaForListElement(context.tableSchema(), key); if (elementListSchema != null) { TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema); - Collection updatedList = new ArrayList<>(value.l().size()); + + Collection updatedList = null; for (AttributeValue listItem : value.l()) { + if (updatedList == null) { + updatedList = new ArrayList<>(value.l().size()); + } + if (hasMap(listItem)) { updatedList.add(AttributeValue.builder() .m(processNestedObject( @@ -192,7 +197,10 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex updatedList.add(listItem); } } - updatedItems.put(key, AttributeValue.builder().l(updatedList).build()); + + if (updatedList != null) { + updatedItems.put(key, AttributeValue.builder().l(updatedList).build()); + } } } }