diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-3b3908c.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-3b3908c.json new file mode 100644 index 000000000000..8270f7ab107f --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-3b3908c.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Added support for @DynamoDbUpdateBehavior on attributes within nested objects. The @DynamoDbUpdateBehavior annotation will only take effect for nested attributes when using IgnoreNullsMode.SCALAR_ONLY." +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java index 2ac27d918202..62059b01fa61 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/extensions/AutoGeneratedTimestampRecordExtension.java @@ -15,12 +15,21 @@ package software.amazon.awssdk.enhanced.dynamodb.extensions; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.hasMap; +import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.getTableSchemaForListElement; +import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.reconstructCompositeKey; +import static software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility.NestedRecordUtils.resolveSchemasPerPath; + import java.time.Clock; import java.time.Instant; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; import software.amazon.awssdk.annotations.NotThreadSafe; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -30,6 +39,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -64,10 +74,23 @@ *

* Every time a new update of the record is successfully written to the database, the timestamp at which it was modified will * be automatically updated. This extension applies the conversions as defined in the attribute convertor. + * The implementation handles both flattened nested parameters (identified by keys separated with + * {@code "_NESTED_ATTR_UPDATE_"}) and entire nested maps or lists, ensuring consistent behavior across both representations. + * If a nested object or list is {@code null}, no timestamp values will be generated for any of its annotated fields. + * The same timestamp value is used for both top-level attributes and all applicable nested fields. + * + *

+ * Note: This implementation uses a temporary cache keyed by {@link TableSchema} instance. + * When updating timestamps in nested objects or lists, the correct {@code TableSchema} must be used for each object. + * This cache ensures that each nested object is processed with its own schema, avoiding redundant lookups and ensuring + * all annotated timestamp fields are updated correctly. + *

*/ @SdkPublicApi @ThreadSafe public final class AutoGeneratedTimestampRecordExtension implements DynamoDbEnhancedClientExtension { + + private static final String NESTED_OBJECT_UPDATE = "_NESTED_ATTR_UPDATE_"; private static final String CUSTOM_METADATA_KEY = "AutoGeneratedTimestampExtension:AutoGeneratedTimestampAttribute"; private static final AutoGeneratedTimestampAttribute AUTO_GENERATED_TIMESTAMP_ATTRIBUTE = new AutoGeneratedTimestampAttribute(); @@ -126,26 +149,195 @@ public static AutoGeneratedTimestampRecordExtension create() { */ @Override public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + Map itemToTransform = new HashMap<>(context.items()); + + Map updatedItems = new HashMap<>(); + Instant currentInstant = clock.instant(); + + // Use TableSchema instance as the cache key + Map, TableSchema> schemaInstanceCache = new HashMap<>(); + + itemToTransform.forEach((key, value) -> { + if (hasMap(value)) { + Optional> nestedSchemaOpt = getNestedSchema(context.tableSchema(), key); + if (nestedSchemaOpt.isPresent()) { + TableSchema nestedSchema = nestedSchemaOpt.get(); + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, nestedSchema); + Map processed = + processNestedObject(value.m(), cachedSchema, currentInstant, schemaInstanceCache); + updatedItems.put(key, AttributeValue.builder().m(processed).build()); + } + } else if (value.hasL() && !value.l().isEmpty()) { + // Check first non-null element to determine if this is a list of maps + AttributeValue firstElement = value.l().stream() + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + + if (hasMap(firstElement)) { + TableSchema elementListSchema = getTableSchemaForListElement(context.tableSchema(), key); + if (elementListSchema != null) { + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema); - Collection customMetadataObject = context.tableMetadata() - .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class).orElse(null); + Collection updatedList = null; + for (AttributeValue listItem : value.l()) { + if (updatedList == null) { + updatedList = new ArrayList<>(value.l().size()); + } - if (customMetadataObject == null) { + if (hasMap(listItem)) { + updatedList.add(AttributeValue.builder() + .m(processNestedObject( + listItem.m(), + cachedSchema, + currentInstant, + schemaInstanceCache)) + .build()); + } else { + updatedList.add(listItem); + } + } + + if (updatedList != null) { + updatedItems.put(key, AttributeValue.builder().l(updatedList).build()); + } + } + } + } + }); + + Map> stringTableSchemaMap = resolveSchemasPerPath(itemToTransform, context.tableSchema()); + + stringTableSchemaMap.forEach((path, schema) -> { + Collection customMetadataObject = schema.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) + .orElse(null); + + if (customMetadataObject != null) { + customMetadataObject.forEach( + key -> { + AttributeConverter converter = schema.converterForAttribute(key); + if (converter != null) { + insertTimestampInItemToTransform(updatedItems, reconstructCompositeKey(path, key), + converter, currentInstant); + } + }); + } + }); + + if (updatedItems.isEmpty()) { return WriteModification.builder().build(); } - Map itemToTransform = new HashMap<>(context.items()); - customMetadataObject.forEach( - key -> insertTimestampInItemToTransform(itemToTransform, key, - context.tableSchema().converterForAttribute(key))); + + itemToTransform.putAll(updatedItems); + return WriteModification.builder() .transformedItem(Collections.unmodifiableMap(itemToTransform)) .build(); } + private static TableSchema getOrCacheSchema( + Map, TableSchema> schemaInstanceCache, TableSchema schema) { + + TableSchema cachedSchema = schemaInstanceCache.get(schema); + if (cachedSchema == null) { + schemaInstanceCache.put(schema, schema); + cachedSchema = schema; + } + return cachedSchema; + } + + private Map processNestedObject(Map nestedMap, TableSchema nestedSchema, + Instant currentInstant, + Map, TableSchema> schemaInstanceCache) { + Map updatedNestedMap = nestedMap; + boolean updated = false; + + Collection customMetadataObject = nestedSchema.tableMetadata() + .customMetadataObject(CUSTOM_METADATA_KEY, Collection.class) + .orElse(null); + + if (customMetadataObject != null) { + for (String key : customMetadataObject) { + AttributeConverter converter = nestedSchema.converterForAttribute(key); + if (converter != null) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + insertTimestampInItemToTransform(updatedNestedMap, String.valueOf(key), + converter, currentInstant); + } + } + } + + for (Map.Entry entry : nestedMap.entrySet()) { + String nestedKey = entry.getKey(); + AttributeValue nestedValue = entry.getValue(); + if (nestedValue.hasM()) { + Optional> childSchemaOpt = getNestedSchema(nestedSchema, nestedKey); + if (childSchemaOpt.isPresent()) { + TableSchema childSchema = childSchemaOpt.get(); + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, childSchema); + Map processed = processNestedObject( + nestedValue.m(), cachedSchema, currentInstant, schemaInstanceCache); + + if (!Objects.equals(processed, nestedValue.m())) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + updatedNestedMap.put(nestedKey, AttributeValue.builder().m(processed).build()); + } + } + } else if (nestedValue.hasL() && !nestedValue.l().isEmpty()) { + // Check first non-null element to determine if this is a list of maps + AttributeValue firstElement = nestedValue.l().stream() + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + if (hasMap(firstElement)) { + TableSchema listElementSchema = getTableSchemaForListElement(nestedSchema, nestedKey); + if (listElementSchema != null) { + TableSchema cachedSchema = getOrCacheSchema(schemaInstanceCache, listElementSchema); + Collection updatedList = new ArrayList<>(nestedValue.l().size()); + boolean listModified = false; + for (AttributeValue listItem : nestedValue.l()) { + if (hasMap(listItem)) { + AttributeValue updatedItem = AttributeValue.builder() + .m(processNestedObject( + listItem.m(), + cachedSchema, + currentInstant, + schemaInstanceCache)) + .build(); + updatedList.add(updatedItem); + if (!Objects.equals(updatedItem, listItem)) { + listModified = true; + } + } else { + updatedList.add(listItem); + } + } + if (listModified) { + if (!updated) { + updatedNestedMap = new HashMap<>(nestedMap); + updated = true; + } + updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build()); + } + } + } + } + } + return updatedNestedMap; + } + private void insertTimestampInItemToTransform(Map itemToTransform, String key, - AttributeConverter converter) { - itemToTransform.put(key, converter.transformFrom(clock.instant())); + AttributeConverter converter, + Instant instant) { + itemToTransform.put(key, converter.transformFrom(instant)); } /** @@ -190,6 +382,7 @@ public void validateType(String attributeName, EnhancedType type, Validate.notNull(type, "type is null"); Validate.notNull(type.rawClass(), "rawClass is null"); Validate.notNull(attributeValueType, "attributeValueType is null"); + validateAttributeName(attributeName); if (!type.rawClass().equals(Instant.class)) { throw new IllegalArgumentException(String.format( @@ -204,5 +397,15 @@ public Consumer modifyMetadata(String attributeName return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, Collections.singleton(attributeName)) .markAttributeAsKey(attributeName, attributeValueType); } + + private static void validateAttributeName(String attributeName) { + if (attributeName.contains(NESTED_OBJECT_UPDATE)) { + throw new IllegalArgumentException( + String.format( + "Attribute name '%s' contains reserved marker '%s' and is not allowed.", + attributeName, + NESTED_OBJECT_UPDATE)); + } + } } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java index 61d750e98a7e..2690f1d27698 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtils.java @@ -28,7 +28,9 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.OperationContext; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; @@ -37,6 +39,8 @@ import software.amazon.awssdk.enhanced.dynamodb.model.Page; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity; +import software.amazon.awssdk.utils.CollectionUtils; +import software.amazon.awssdk.utils.StringUtils; @SdkInternalApi public final class EnhancedClientUtils { @@ -146,7 +150,7 @@ public static Page readAndTransformPaginatedItems( .scannedCount(scannedCount.apply(response)) .consumedCapacity(consumedCapacity.apply(response)); - if (getLastEvaluatedKey.apply(response) != null && !getLastEvaluatedKey.apply(response).isEmpty()) { + if (CollectionUtils.isNotEmpty(getLastEvaluatedKey.apply(response))) { pageBuilder.lastEvaluatedKey(getLastEvaluatedKey.apply(response)); } return pageBuilder.build(); @@ -204,4 +208,42 @@ public static List getItemsFromSupplier(List> itemSupplierLis public static boolean isNullAttributeValue(AttributeValue attributeValue) { return attributeValue.nul() != null && attributeValue.nul(); } + + public static boolean hasMap(AttributeValue attributeValue) { + return attributeValue != null && attributeValue.hasM(); + } + + /** + * Retrieves the nested {@link TableSchema} for an attribute from the parent schema. + * For parameterized types (e.g., Set, List, Map), extracts the first type parameter's schema. + * + * @param parentSchema the parent schema; must not be null + * @param attributeName the attribute name; must not be null or empty + * @return the nested schema, or empty if unavailable + */ + public static Optional> getNestedSchema(TableSchema parentSchema, CharSequence attributeName) { + if (parentSchema == null) { + throw new IllegalArgumentException("Parent schema cannot be null."); + } + if (StringUtils.isEmpty(attributeName)) { + throw new IllegalArgumentException("Attribute name cannot be null or empty."); + } + + AttributeConverter converter = parentSchema.converterForAttribute(attributeName); + if (converter == null) { + return Optional.empty(); + } + + EnhancedType enhancedType = converter.type(); + if (enhancedType == null) { + return Optional.empty(); + } + + List> rawClassParameters = enhancedType.rawClassParameters(); + if (!CollectionUtils.isNullOrEmpty(rawClassParameters)) { + enhancedType = rawClassParameters.get(0); + } + + return enhancedType.tableSchema(); + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java new file mode 100644 index 000000000000..0cdfa3577922 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java @@ -0,0 +1,168 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.internal.extensions.utility; + +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema; +import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.CollectionUtils; +import software.amazon.awssdk.utils.StringUtils; + +@SdkInternalApi +public final class NestedRecordUtils { + + private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE); + + private NestedRecordUtils() { + } + + /** + * Resolves and returns the {@link TableSchema} for the element type of list attribute from the provided root schema. + *

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

+ * If the provided key contains the nested object delimiter (e.g., {@code _NESTED_ATTR_UPDATE_}), the method traverses the + * nested hierarchy based on that path to locate the correct schema for the target attribute. Otherwise, it directly resolves + * the list element type from the root schema using reflection. + * + * @param rootSchema The root {@link TableSchema} representing the top-level entity. + * @param key The key representing the list attribute, either flat or nested (using a delimiter). + * @return The {@link TableSchema} representing the list element type of the specified attribute. + * @throws IllegalArgumentException If the list element class cannot be found via reflection. + */ + public static TableSchema getTableSchemaForListElement(TableSchema rootSchema, String key) { + TableSchema listElementSchema; + try { + if (!key.contains(NESTED_OBJECT_UPDATE)) { + Optional> staticSchema = getNestedSchema(rootSchema, key); + if (staticSchema.isPresent()) { + listElementSchema = staticSchema.get(); + } else { + AttributeConverter converter = rootSchema.converterForAttribute(key); + if (converter == null) { + throw new IllegalArgumentException("No converter found for attribute: " + key); + } + List> rawClassParameters = converter.type().rawClassParameters(); + if (CollectionUtils.isNullOrEmpty(rawClassParameters)) { + throw new IllegalArgumentException("No type parameters found for list attribute: " + key); + } + listElementSchema = TableSchema.fromClass( + Class.forName(rawClassParameters.get(0).rawClass().getName())); + } + + } else { + String[] parts = NESTED_OBJECT_PATTERN.split(key); + TableSchema currentSchema = rootSchema; + + for (int i = 0; i < parts.length - 1; i++) { + Optional> 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; + } + + /** + * Converts a dot-separated path to a composite key using nested object delimiters. Example: + * {@code reconstructCompositeKey("parent.child", "attr")} returns + * {@code "parent_NESTED_ATTR_UPDATE_child_NESTED_ATTR_UPDATE_attr"} + * + * @param path the dot-separated path; may be null or empty + * @param attributeName the attribute name to append; must not be null + * @return the composite key with nested object delimiters + */ + public static String reconstructCompositeKey(String path, String attributeName) { + if (attributeName == null) { + throw new IllegalArgumentException("Attribute name cannot be null"); + } + + if (StringUtils.isEmpty(path)) { + return attributeName; + } + + return String.join(NESTED_OBJECT_UPDATE, path.split("\\.")) + + NESTED_OBJECT_UPDATE + attributeName; + } +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java index 0ffe361b5aed..3b089446736a 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java @@ -132,7 +132,7 @@ public UpdateItemRequest generateRequest(TableSchema tableSchema, Map keyAttributes = filterMap(itemMap, entry -> primaryKeys.contains(entry.getKey())); Map nonKeyAttributes = filterMap(itemMap, entry -> !primaryKeys.contains(entry.getKey())); - Expression updateExpression = generateUpdateExpressionIfExist(tableMetadata, transformation, nonKeyAttributes); + Expression updateExpression = generateUpdateExpressionIfExist(tableSchema, transformation, nonKeyAttributes); Expression conditionExpression = generateConditionExpressionIfExist(transformation, request); Map expressionNames = coalesceExpressionNames(updateExpression, conditionExpression); @@ -171,6 +171,7 @@ public Map transformItemToMapForUpdateExpression(Map nestedAttributes = new HashMap<>(); itemToMap.forEach((key, value) -> { + validateAttributeName(key); if (value.hasM() && isNotEmptyMap(value.m())) { nestedAttributes.put(key, value); } @@ -192,8 +193,9 @@ private Map nestedItemToMap(Map String key, AttributeValue attributeValue) { attributeValue.m().forEach((mapKey, mapValue) -> { - String nestedAttributeKey = key + NESTED_OBJECT_UPDATE + mapKey; + validateAttributeName(mapKey); if (attributeValueNonNullOrShouldWriteNull(mapValue)) { + String nestedAttributeKey = key + NESTED_OBJECT_UPDATE + mapKey; if (mapValue.hasM()) { nestedItemToMap(itemToMap, nestedAttributeKey, mapValue); } else { @@ -275,7 +277,7 @@ public TransactWriteItem generateTransactWriteItem(TableSchema tableSchema, O * if there are attributes to be updated (most likely). If both exist, they are merged and the code generates a final * Expression that represent the result. */ - private Expression generateUpdateExpressionIfExist(TableMetadata tableMetadata, + private Expression generateUpdateExpressionIfExist(TableSchema tableSchema, WriteModification transformation, Map attributes) { UpdateExpression updateExpression = null; @@ -284,7 +286,7 @@ private Expression generateUpdateExpressionIfExist(TableMetadata tableMetadata, } if (!attributes.isEmpty()) { List nonRemoveAttributes = UpdateExpressionConverter.findAttributeNames(updateExpression); - UpdateExpression operationUpdateExpression = operationExpression(attributes, tableMetadata, nonRemoveAttributes); + UpdateExpression operationUpdateExpression = operationExpression(attributes, tableSchema, nonRemoveAttributes); if (updateExpression == null) { updateExpression = operationUpdateExpression; } else { @@ -355,4 +357,12 @@ private static Map coalesceExpressionValues(Expression f } return expressionValues; } + + private static void validateAttributeName(String attributeName) { + if (attributeName.contains(NESTED_OBJECT_UPDATE)) { + throw new IllegalArgumentException( + String.format("Attribute name '%s' contains reserved marker '%s' and is not allowed.", + attributeName, NESTED_OBJECT_UPDATE)); + } + } } diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java index 1d47400ab2e6..4ad1989d057d 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java @@ -15,21 +15,24 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.update; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.getNestedSchema; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; import static software.amazon.awssdk.utils.CollectionUtils.filterMap; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Collectors; import software.amazon.awssdk.annotations.SdkInternalApi; -import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils; import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.UpdateBehaviorTag; import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; @@ -57,12 +60,12 @@ public static String ifNotExists(String key, String initValue) { * Generates an UpdateExpression representing a POJO, with only SET and REMOVE actions. */ public static UpdateExpression operationExpression(Map itemMap, - TableMetadata tableMetadata, + TableSchema tableSchema, List nonRemoveAttributes) { Map setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue())); UpdateExpression setAttributeExpression = UpdateExpression.builder() - .actions(setActionsFor(setAttributes, tableMetadata)) + .actions(setActionsFor(setAttributes, tableSchema)) .build(); Map removeAttributes = @@ -78,13 +81,31 @@ public static UpdateExpression operationExpression(Map i /** * Creates a list of SET actions for all attributes supplied in the map. */ - private static List setActionsFor(Map attributesToSet, TableMetadata tableMetadata) { - return attributesToSet.entrySet() - .stream() - .map(entry -> setValue(entry.getKey(), - entry.getValue(), - UpdateBehaviorTag.resolveForAttribute(entry.getKey(), tableMetadata))) - .collect(Collectors.toList()); + private static List setActionsFor(Map attributesToSet, TableSchema tableSchema) { + List actions = new ArrayList<>(); + for (Map.Entry entry : attributesToSet.entrySet()) { + String key = entry.getKey(); + AttributeValue value = entry.getValue(); + + if (key.contains(NESTED_OBJECT_UPDATE)) { + TableSchema currentSchema = tableSchema; + List pathFieldNames = Arrays.asList(PATTERN.split(key)); + String attributeName = pathFieldNames.get(pathFieldNames.size() - 1); + + for (int i = 0; i < pathFieldNames.size() - 1; i++) { + Optional> nestedSchema = getNestedSchema(currentSchema, pathFieldNames.get(i)); + if (nestedSchema.isPresent()) { + currentSchema = nestedSchema.get(); + } + } + + actions.add(setValue(key, value, + UpdateBehaviorTag.resolveForAttribute(attributeName, currentSchema.tableMetadata()))); + } else { + actions.add(setValue(key, value, UpdateBehaviorTag.resolveForAttribute(key, tableSchema.tableMetadata()))); + } + } + return actions; } /** diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbUpdateBehavior.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbUpdateBehavior.java index fa161446c1a4..07e71e00c38e 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbUpdateBehavior.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/mapper/annotations/DynamoDbUpdateBehavior.java @@ -22,10 +22,19 @@ import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.enhanced.dynamodb.internal.mapper.BeanTableSchemaAttributeTags; import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; +import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode; /** - * Specifies the behavior when this attribute is updated as part of an 'update' operation such as UpdateItem. See - * documentation of {@link UpdateBehavior} for details on the different behaviors supported and the default behavior. + * Specifies the behavior when this attribute is updated as part of an 'update' operation such as UpdateItem. See documentation of + * {@link UpdateBehavior} for details on the different behaviors supported and the default behavior. For attributes within nested + * objects, this annotation is only respected when the request uses {@link IgnoreNullsMode#SCALAR_ONLY}. In + * {@link IgnoreNullsMode#MAPS_ONLY} or {@link IgnoreNullsMode#DEFAULT}, the annotation has no effect. When applied to a list of + * nested objects, the annotation is not supported, as individual elements cannot be updated — the entire list is replaced during + * an update operation. + *

+ * Note: This annotation must not be applied to fields whose names contain the reserved marker "_NESTED_ATTR_UPDATE_". This marker + * is used internally by the Enhanced Client to represent flattened paths for nested attribute updates. If a field name contains + * this marker, an IllegalArgumentException will be thrown during schema registration. */ @SdkPublicApi @Target({ElementType.METHOD}) diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/NestedUpdateBehaviorTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/NestedUpdateBehaviorTest.java new file mode 100644 index 000000000000..39a0670891ac --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/NestedUpdateBehaviorTest.java @@ -0,0 +1,478 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedStaticChildRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticImmutableSchemaForNestedRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticImmutableSchemaForSimpleRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticImmutableSchema_NoChildSchemaDefined; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticSchemaForNestedRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticSchemaForNestedRecord_NoChildSchemaDefined; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.buildStaticSchemaForSimpleRecord; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedImmutableRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.NestedStaticRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleImmutableRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.SimpleStaticRecord; +import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.ImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode; +import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; + +public class NestedUpdateBehaviorTest extends LocalDynamoDbSyncTestBase { + + private static final String TIMESTAMP_STRING = "2019-01-13T14:00:00Z"; + private static final String BASE_TABLE_NAME = "update-behavior-test"; + private static final Clock mockClock = Mockito.mock(Clock.class); + private static final Instant MOCKED_INSTANT_NOW = Instant.now(Clock.fixed(Instant.parse(TIMESTAMP_STRING), ZoneOffset.UTC)); + + private DynamoDbEnhancedClient enhancedClient; + private String currentTestTableName; + + @Before + public void beforeClass() { + Mockito.when(mockClock.instant()).thenReturn(MOCKED_INSTANT_NOW); + enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(AutoGeneratedTimestampRecordExtension.builder() + .baseClock(mockClock) + .build()) + .build(); + } + + @After + public void deleteTable() { + try { + if (currentTestTableName != null) { + getDynamoDbClient().deleteTable(r -> r.tableName(currentTestTableName)); + } + } catch (ResourceNotFoundException e) { + // Table didn't get created, ignore. + } + } + + @Test + public void beanSchema_simpleRecord_updateBehavior_isRespected() { + TableSchema schema = BeanTableSchema.create(SimpleBean.class); + + SimpleBean item = UpdateBehaviorTestModels.createSimpleBean(); + + DynamoDbTable table = createAndPut("simple-bean-update-behavior", schema, item); + + SimpleBean result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways"); + assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce"); + assertThat(result.getChildList()).hasSize(2); + + // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields + // Note: We only update root-level attributes, not child list items, because + // list updates replace the entire list, overriding individual child update behaviors + result.setWriteAlwaysField("updated_writeAlways"); + result.setWriteOnceField("updated_writeOnce"); + + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleBean updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Root level assertions + assertThat(updated.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change + assertThat(updated.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change + + // Child list should remain unchanged since we didn't update it + assertThat(updated.getChildList()).hasSize(2); + assertThat(updated.getChildList().get(0).getChildAlwaysUpdate()).isEqualTo("child1_initial_writeAlways"); + assertThat(updated.getChildList().get(0).getChildWriteOnce()).isEqualTo("child1_initial_writeOnce"); + assertThat(updated.getChildList().get(1).getChildAlwaysUpdate()).isEqualTo("child2_initial_writeAlways"); + assertThat(updated.getChildList().get(1).getChildWriteOnce()).isEqualTo("child2_initial_writeOnce"); + + table.deleteTable(); + } + + @Test + public void beanSchema_nestedRecord_updateBehavior_isRespected() { + TableSchema schema = BeanTableSchema.create(NestedBean.class); + + NestedBean item = UpdateBehaviorTestModels.createNestedBean(); + + DynamoDbTable table = createAndPut("nested-bean-update-behavior", schema, item); + + NestedBean result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways"); + assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce"); + assertThat(result.getChild()).isNotNull(); + assertThat(result.getChild().getChildAlwaysUpdate()).isEqualTo("child_initial_writeAlways"); + assertThat(result.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce"); + + // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields + result.setWriteAlwaysField("updated_writeAlways"); + result.setWriteOnceField("updated_writeOnce"); + result.getChild().setChildAlwaysUpdate("child_updated_writeAlways"); + result.getChild().setChildWriteOnce("child_updated_writeOnce"); + + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + NestedBean updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Root level assertions + assertThat(updated.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change + assertThat(updated.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change + + // Child level assertions + assertThat(updated.getChild().getChildAlwaysUpdate()).isEqualTo("child_updated_writeAlways"); // WRITE_ALWAYS: should + // change + assertThat(updated.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should + // NOT change + + table.deleteTable(); + } + + @Test + public void immutableSchema_simpleRecord_updateBehavior_isRespected() { + TableSchema schema = ImmutableTableSchema.create(SimpleImmutableRecord.class); + + SimpleImmutableRecord item = SimpleImmutableRecord + .builder() + .id("1") + .writeAlwaysField("initial_writeAlways") + .writeOnceField("initial_writeOnce") + .childList(Arrays.asList( + UpdateBehaviorTestModels.SimpleImmutableChild.builder() + .id("child1") + .childAlwaysUpdate("child1_initial_writeAlways") + .childWriteOnce("child1_initial_writeOnce") + .build(), + UpdateBehaviorTestModels.SimpleImmutableChild.builder() + .id("child2") + .childAlwaysUpdate("child2_initial_writeAlways") + .childWriteOnce("child2_initial_writeOnce") + .build())) + .build(); + + DynamoDbTable table = createAndPut("simple-immutable-update-behavior", schema, item); + + SimpleImmutableRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways"); + assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce"); + assertThat(result.getChildList()).hasSize(2); + + // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields + // Note: We only update root-level attributes, not child list items, because + // list updates replace the entire list, overriding individual child update behaviors + SimpleImmutableRecord updated = SimpleImmutableRecord.builder() + .id("1") + .writeAlwaysField("updated_writeAlways") + .writeOnceField("updated_writeOnce") + .build(); + + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Root level assertions + assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change + assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change + + // Child list should remain unchanged since we didn't update it + assertThat(afterUpdate.getChildList()).hasSize(2); + assertThat(afterUpdate.getChildList().get(0).getChildAlwaysUpdate()).isEqualTo("child1_initial_writeAlways"); + assertThat(afterUpdate.getChildList().get(0).getChildWriteOnce()).isEqualTo("child1_initial_writeOnce"); + assertThat(afterUpdate.getChildList().get(1).getChildAlwaysUpdate()).isEqualTo("child2_initial_writeAlways"); + assertThat(afterUpdate.getChildList().get(1).getChildWriteOnce()).isEqualTo("child2_initial_writeOnce"); + + table.deleteTable(); + } + + @Test + public void immutableSchema_nestedRecord_updateBehavior_isRespected() { + TableSchema schema = ImmutableTableSchema.create(NestedImmutableRecord.class); + + NestedImmutableRecord item = NestedImmutableRecord + .builder() + .id("1") + .writeAlwaysField("initial_writeAlways") + .writeOnceField("initial_writeOnce") + .child(UpdateBehaviorTestModels.NestedImmutableChild + .builder() + .childAlwaysUpdate("child_initial_writeAlways") + .childWriteOnce("child_initial_writeOnce") + .build()) + .build(); + + DynamoDbTable table = createAndPut("nested-immutable-update-behavior", schema, item); + + NestedImmutableRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways"); + assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce"); + assertThat(result.getChild()).isNotNull(); + assertThat(result.getChild().getChildAlwaysUpdate()).isEqualTo("child_initial_writeAlways"); + assertThat(result.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce"); + + // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields + NestedImmutableRecord updated = NestedImmutableRecord.builder() + .id("1") + .writeAlwaysField("updated_writeAlways") + .writeOnceField("updated_writeOnce") + .build(); + + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + NestedImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Root level assertions + assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change + assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change + + table.deleteTable(); + } + + @Test + public void staticSchema_simpleRecord_updateBehavior_isRespected() { + SimpleStaticRecord item = new SimpleStaticRecord() + .setId("1") + .setWriteAlwaysField("initial_writeAlways") + .setWriteOnceField("initial_writeOnce"); + + DynamoDbTable table = createAndPut("simple-static-update-behavior", + buildStaticSchemaForSimpleRecord(), item); + + SimpleStaticRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways"); + assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce"); + + // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields + result.setWriteAlwaysField("updated_writeAlways"); + result.setWriteOnceField("updated_writeOnce"); + + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleStaticRecord updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Root level assertions + assertThat(updated.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change + assertThat(updated.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change + + table.deleteTable(); + } + + @Test + public void staticSchema_nestedRecord_updateBehavior_isRespected() { + NestedStaticRecord item = new NestedStaticRecord() + .setId("1") + .setWriteAlwaysField("initial_writeAlways") + .setWriteOnceField("initial_writeOnce") + .setChild(new NestedStaticChildRecord() + .setChildAlwaysUpdate("child_initial_writeAlways") + .setChildWriteOnce("child_initial_writeOnce")); + + DynamoDbTable table = createAndPut("nested-static-update-behavior", + buildStaticSchemaForNestedRecord(), item); + + NestedStaticRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways"); + assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce"); + assertThat(result.getChild()).isNotNull(); + assertThat(result.getChild().getChildAlwaysUpdate()).isEqualTo("child_initial_writeAlways"); + assertThat(result.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce"); + + // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields + result.setWriteAlwaysField("updated_writeAlways"); + result.setWriteOnceField("updated_writeOnce"); + result.getChild().setChildAlwaysUpdate("child_updated_writeAlways"); + result.getChild().setChildWriteOnce("child_updated_writeOnce"); + + table.updateItem(r -> r.item(result).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + NestedStaticRecord updated = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // Root level assertions + assertThat(updated.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // WRITE_ALWAYS: should change + assertThat(updated.getWriteOnceField()).isEqualTo("initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should NOT change + + // Child level assertions + assertThat(updated.getChild().getChildAlwaysUpdate()).isEqualTo("child_updated_writeAlways"); // WRITE_ALWAYS: should + // change + assertThat(updated.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce"); // WRITE_IF_NOT_EXISTS: should + // NOT change + + table.deleteTable(); + } + + @Test + public void staticImmutableSchema_simpleRecord_updateBehavior_isRespected() { + SimpleImmutableRecord item = SimpleImmutableRecord + .builder() + .id("1") + .writeAlwaysField("initial_writeAlways") + .writeOnceField("initial_writeOnce") + .childList(Arrays.asList( + UpdateBehaviorTestModels.SimpleImmutableChild.builder() + .id("child1") + .childAlwaysUpdate("child1_initial_writeAlways") + .childWriteOnce("child1_initial_writeOnce") + .build(), + UpdateBehaviorTestModels.SimpleImmutableChild.builder() + .id("child2") + .childAlwaysUpdate("child2_initial_writeAlways") + .childWriteOnce("child2_initial_writeOnce") + .build())) + .build(); + + DynamoDbTable table = createAndPut("simple-static-immutable-update-behavior", + buildStaticImmutableSchemaForSimpleRecord(), item); + + SimpleImmutableRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways"); + assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce"); + assertThat(result.getChildList()).hasSize(2); + + // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields + // Note: We only update root-level attributes, not child list items, because + // list updates replace the entire list, overriding individual child update behaviors + SimpleImmutableRecord updated = SimpleImmutableRecord.builder() + .id("1") + .writeAlwaysField("updated_writeAlways") + .writeOnceField("updated_writeOnce") + .build(); + + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + SimpleImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // WRITE_ALWAYS fields should be updated + assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // should change + + // WRITE_IF_NOT_EXISTS fields should NOT be updated (keep original values) + assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // should NOT change + + // Child list should remain unchanged since we didn't update it + assertThat(afterUpdate.getChildList()).hasSize(2); + assertThat(afterUpdate.getChildList().get(0).getChildAlwaysUpdate()).isEqualTo("child1_initial_writeAlways"); + assertThat(afterUpdate.getChildList().get(0).getChildWriteOnce()).isEqualTo("child1_initial_writeOnce"); + assertThat(afterUpdate.getChildList().get(1).getChildAlwaysUpdate()).isEqualTo("child2_initial_writeAlways"); + assertThat(afterUpdate.getChildList().get(1).getChildWriteOnce()).isEqualTo("child2_initial_writeOnce"); + + table.deleteTable(); + } + + @Test + public void staticImmutableSchema_nestedRecord_updateBehavior_isRespected() { + NestedImmutableRecord item = NestedImmutableRecord.builder() + .id("1") + .writeAlwaysField("initial_writeAlways") + .writeOnceField("initial_writeOnce") + .child(UpdateBehaviorTestModels.NestedImmutableChild + .builder() + .childAlwaysUpdate("child_initial_writeAlways") + .childWriteOnce("child_initial_writeOnce") + .build()) + .build(); + + DynamoDbTable table = createAndPut("nested-static-immutable-update-behavior", + buildStaticImmutableSchemaForNestedRecord(), item); + + NestedImmutableRecord result = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + assertThat(result.getWriteAlwaysField()).isEqualTo("initial_writeAlways"); + assertThat(result.getWriteOnceField()).isEqualTo("initial_writeOnce"); + assertThat(result.getChild()).isNotNull(); + assertThat(result.getChild().getChildAlwaysUpdate()).isEqualTo("child_initial_writeAlways"); + assertThat(result.getChild().getChildWriteOnce()).isEqualTo("child_initial_writeOnce"); + + // Update with new values for both WRITE_ALWAYS and WRITE_IF_NOT_EXISTS fields + NestedImmutableRecord updated = NestedImmutableRecord.builder() + .id("1") + .writeAlwaysField("updated_writeAlways") + .writeOnceField("updated_writeOnce") + .build(); + + table.updateItem(r -> r.item(updated).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + NestedImmutableRecord afterUpdate = table.getItem(r -> r.key(k -> k.partitionValue("1"))); + + // WRITE_ALWAYS fields should be updated + assertThat(afterUpdate.getWriteAlwaysField()).isEqualTo("updated_writeAlways"); // should change + + // WRITE_IF_NOT_EXISTS fields should NOT be updated (keep original values) + assertThat(afterUpdate.getWriteOnceField()).isEqualTo("initial_writeOnce"); // should NOT change + + table.deleteTable(); + } + + @Test + public void buildStaticSchema_forNestedRecordWithChildSchemaNotDefined_throwsIllegalStateException() { + NestedStaticRecord item = new NestedStaticRecord() + .setId("1") + .setWriteAlwaysField("initial_writeAlways") + .setWriteOnceField("initial_writeOnce") + .setChild(new NestedStaticChildRecord() + .setChildAlwaysUpdate("child_initial_writeAlways") + .setChildWriteOnce("child_initial_writeOnce")); + + assertThatIllegalStateException() + .isThrownBy(() -> createAndPut("nested-static-update-behavior", + buildStaticSchemaForNestedRecord_NoChildSchemaDefined(), + item)) + + .withMessageContaining("Converter not found for EnhancedType(software.amazon.awssdk.enhanced.dynamodb" + + ".functionaltests.models.UpdateBehaviorTestModels$NestedStaticChildRecord)"); + } + + @Test + public void buildStaticImmutableSchema_forNestedRecordWithChildSchemaNotDefined_throwsIllegalStateException() { + NestedImmutableRecord item = NestedImmutableRecord.builder() + .id("1") + .writeAlwaysField("initial_writeAlways") + .writeOnceField("initial_writeOnce") + .child(UpdateBehaviorTestModels.NestedImmutableChild + .builder() + .childAlwaysUpdate("child_initial_writeAlways") + .childWriteOnce("child_initial_writeOnce") + .build()) + .build(); + + assertThatIllegalStateException() + .isThrownBy(() -> createAndPut("nested-static-immutable-update-behavior", + buildStaticImmutableSchema_NoChildSchemaDefined(), + item)) + + .withMessageContaining("Converter not found for EnhancedType(software.amazon.awssdk.enhanced.dynamodb" + + ".functionaltests.models.UpdateBehaviorTestModels$NestedImmutableChild)"); + } + + private DynamoDbTable createAndPut(String tableSuffix, TableSchema schema, T item) { + currentTestTableName = BASE_TABLE_NAME + tableSuffix + System.nanoTime(); + DynamoDbTable table = enhancedClient.table(currentTestTableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + table.putItem(item); + return table; + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java index 196d38282277..6726c2d94631 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateBehaviorTest.java @@ -2,21 +2,31 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.BeanWithInvalidNestedAttributeName.ChildBeanWithInvalidAttributeName; +import com.google.common.collect.ImmutableList; import java.time.Instant; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.CompositeRecord; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FlattenRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.BeanWithInvalidNestedAttributeName; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.UpdateBehaviorTestModels.BeanWithInvalidRootAttributeName; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordWithUpdateBehavior; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordWithUpdateBehavior.TimestampListElement; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordWithUpdateBehaviors; import software.amazon.awssdk.enhanced.dynamodb.internal.client.ExtensionResolver; import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode; @@ -33,23 +43,38 @@ public class UpdateBehaviorTest extends LocalDynamoDbSyncTestBase { private static final String TEST_ATTRIBUTE = "testAttribute"; private static final TableSchema TABLE_SCHEMA = - TableSchema.fromClass(RecordWithUpdateBehaviors.class); - + TableSchema.fromClass(RecordWithUpdateBehaviors.class); + private static final TableSchema TABLE_SCHEMA_FLATTEN_RECORD = TableSchema.fromClass(FlattenRecord.class); + private static final TableSchema TABLE_SCHEMA_INVALID_ATTR_NAME_ON_ROOT = + TableSchema.fromClass(BeanWithInvalidRootAttributeName.class); + + private static final TableSchema TABLE_SCHEMA_INVALID_ATTR_NAME_ON_NESTED_LEVEL = + TableSchema.fromClass(BeanWithInvalidNestedAttributeName.class); + private final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(getDynamoDbClient()).extensions( + .dynamoDbClient(getDynamoDbClient()).extensions( Stream.concat(ExtensionResolver.defaultExtensions().stream(), Stream.of(AutoGeneratedTimestampRecordExtension.create())).collect(Collectors.toList())) - .build(); + .build(); private final DynamoDbTable mappedTable = - enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); - + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + private final DynamoDbTable flattenedMappedTable = enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA_FLATTEN_RECORD); + private final DynamoDbTable beanWithInvalidRootAttrNameMappedTable = + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA_INVALID_ATTR_NAME_ON_ROOT); + + private final DynamoDbTable beanWithInvalidNestedAttrNameMappedTable = + enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA_INVALID_ATTR_NAME_ON_NESTED_LEVEL); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + @Before public void createTable() { mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); @@ -62,11 +87,16 @@ public void deleteTable() { @Test public void updateBehaviors_firstUpdate() { - Instant currentTime = Instant.now(); + Instant currentTime = Instant.now().minusMillis(1); + NestedRecordWithUpdateBehavior nestedRecord = new NestedRecordWithUpdateBehavior(); + nestedRecord.setId("id167"); + nestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE); + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); record.setId("id123"); record.setCreatedOn(INSTANT_1); record.setLastUpdatedOn(INSTANT_2); + record.setNestedRecord(nestedRecord); mappedTable.updateItem(record); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(record); @@ -81,28 +111,57 @@ public void updateBehaviors_firstUpdate() { assertThat(persistedRecord.getLastAutoUpdatedOnMillis().getEpochSecond()).isGreaterThanOrEqualTo(currentTime.getEpochSecond()); assertThat(persistedRecord.getCreatedAutoUpdateOn()).isAfterOrEqualTo(currentTime); + + assertThat(persistedRecord.getNestedRecord().getId()).isEqualTo("id167"); + assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isAfterOrEqualTo(currentTime); + assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfterOrEqualTo(currentTime); + assertThat(persistedRecord.getCreatedAutoUpdateOn()).isAfterOrEqualTo(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()); + assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isEqualTo(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()); } @Test public void updateBehaviors_secondUpdate() { - Instant beforeUpdateInstant = Instant.now(); + Instant beforeUpdateInstant = Instant.now().minusMillis(1); + + NestedRecordWithUpdateBehavior secondNestedRecord = new NestedRecordWithUpdateBehavior(); + secondNestedRecord.setId("id199"); + secondNestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE); + + NestedRecordWithUpdateBehavior nestedRecord = new NestedRecordWithUpdateBehavior(); + nestedRecord.setId("id155"); + nestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE); + nestedRecord.setNestedRecord(secondNestedRecord); + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); record.setId("id123"); record.setCreatedOn(INSTANT_1); record.setLastUpdatedOn(INSTANT_2); + record.setNestedRecord(nestedRecord); mappedTable.updateItem(record); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(record); assertThat(persistedRecord.getVersion()).isEqualTo(1L); + Instant firstUpdatedTime = persistedRecord.getLastAutoUpdatedOn(); Instant createdAutoUpdateOn = persistedRecord.getCreatedAutoUpdateOn(); + assertThat(firstUpdatedTime).isAfterOrEqualTo(beforeUpdateInstant); assertThat(persistedRecord.getFormattedLastAutoUpdatedOn().getEpochSecond()) .isGreaterThanOrEqualTo(beforeUpdateInstant.getEpochSecond()); + assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isNotNull(); + assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()) + .isEqualTo(firstUpdatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()) + .isEqualTo(firstUpdatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedCreatedTimeAttribute()) + .isEqualTo(firstUpdatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedUpdatedTimeAttribute()) + .isEqualTo(firstUpdatedTime); record.setVersion(1L); record.setCreatedOn(INSTANT_2); record.setLastUpdatedOn(INSTANT_2); + record.setNestedRecord(nestedRecord); mappedTable.updateItem(record); persistedRecord = mappedTable.getItem(record); @@ -113,6 +172,14 @@ public void updateBehaviors_secondUpdate() { Instant secondUpdatedTime = persistedRecord.getLastAutoUpdatedOn(); assertThat(secondUpdatedTime).isAfterOrEqualTo(firstUpdatedTime); assertThat(persistedRecord.getCreatedAutoUpdateOn()).isEqualTo(createdAutoUpdateOn); + assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()) + .isEqualTo(secondUpdatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()) + .isEqualTo(secondUpdatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedCreatedTimeAttribute()) + .isEqualTo(secondUpdatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedRecord().getNestedUpdatedTimeAttribute()) + .isEqualTo(secondUpdatedTime); } @Test @@ -164,7 +231,7 @@ public void updateBehaviors_transactWriteItems_secondUpdate() { @Test public void when_updatingNestedObjectWithSingleLevel_existingInformationIsPreserved_scalar_only_update() { - + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); @@ -173,26 +240,35 @@ public void when_updatingNestedObjectWithSingleLevel_existingInformationIsPreser mappedTable.putItem(record); + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + Instant nestedCreatedTime = persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute(); + Instant nestedUpdatedTime = persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute(); + assertThat(nestedCreatedTime).isAfter(currentTime); + assertThat(nestedUpdatedTime).isEqualTo(nestedCreatedTime); + NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); long updatedNestedCounter = 10L; updatedNestedRecord.setNestedCounter(updatedNestedCounter); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setNestedRecord(updatedNestedRecord); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord); - mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, - TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); + TEST_BEHAVIOUR_ATTRIBUTE, currentTime); + assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isEqualTo(nestedCreatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfter(nestedUpdatedTime); } @Test public void when_updatingNestedObjectWithSingleLevel_default_mode_update_newMapCreated() { - + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); @@ -201,25 +277,35 @@ public void when_updatingNestedObjectWithSingleLevel_default_mode_update_newMapC mappedTable.putItem(record); + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + Instant nestedCreatedTime = persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute(); + Instant nestedUpdatedTime = persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute(); + assertThat(nestedCreatedTime).isNotNull(); + assertThat(nestedUpdatedTime).isEqualTo(nestedCreatedTime); + NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); long updatedNestedCounter = 10L; updatedNestedRecord.setNestedCounter(updatedNestedCounter); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setNestedRecord(updatedNestedRecord); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord); - mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.DEFAULT)); + mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.DEFAULT)); - RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null); + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, + currentTime); + assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isAfter(nestedCreatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfter(nestedUpdatedTime); } @Test public void when_updatingNestedObjectWithSingleLevel_with_no_mode_update_newMapCreated() { - + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); @@ -232,16 +318,74 @@ public void when_updatingNestedObjectWithSingleLevel_with_no_mode_update_newMapC long updatedNestedCounter = 10L; updatedNestedRecord.setNestedCounter(updatedNestedCounter); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setNestedRecord(updatedNestedRecord); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord); + + mappedTable.updateItem(r -> r.item(updateRecord)); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, + currentTime); + } + + @Test + public void when_updatingNestedObjectList_no_matter_mode_update_newListCreated_with_timestampGenerated() { + Instant currentTime = Instant.now().minusMillis(1); + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); + nestedRecord.setNestedUpdatedTimeAttribute(null); + TimestampListElement firstElement = new TimestampListElement(); + firstElement.setId("id1"); + firstElement.setAttribute("attr1"); + TimestampListElement secondElement = new TimestampListElement(); + secondElement.setId("id2"); + secondElement.setAttribute("attr2"); + nestedRecord.setNestedRecordList(ImmutableList.of(firstElement, secondElement)); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord); + record.setNestedRecordList(ImmutableList.of(firstElement, secondElement)); - mappedTable.updateItem(r -> r.item(update_record)); + mappedTable.putItem(record); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), updatedNestedCounter, null, null); + List nestedRecordList = persistedRecord.getNestedRecord().getNestedRecordList(); + Instant firstOperationTime = nestedRecordList.get(0).getTimeAttributeElement(); + + assertThat(persistedRecord.getNestedRecordList().get(0).getTimeAttributeElement()).isAfter(currentTime); + assertThat(persistedRecord.getNestedRecordList().get(1).getTimeAttributeElement()).isAfter(currentTime); + assertThat(nestedRecordList.get(0).getTimeAttributeElement()).isAfter(currentTime); + assertThat(nestedRecordList.get(1).getTimeAttributeElement()).isEqualTo(firstOperationTime); + + NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); + long updatedNestedCounter = 10L; + updatedNestedRecord.setNestedUpdatedTimeAttribute(null); + firstElement.setAttribute("attr44"); + secondElement.setAttribute("attr55"); + updatedNestedRecord.setNestedCounter(updatedNestedCounter); + updatedNestedRecord.setNestedRecordList(ImmutableList.of(firstElement, secondElement)); + + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord); + updateRecord.setNestedRecordList(ImmutableList.of(firstElement)); + + mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + nestedRecordList = persistedRecord.getNestedRecord().getNestedRecordList(); + + assertThat(persistedRecord.getNestedRecordList()).hasSize(1); + assertThat(persistedRecord.getNestedRecordList().get(0).getTimeAttributeElement()).isAfter(firstOperationTime); + assertThat(nestedRecordList).hasSize(2); + assertThat(nestedRecordList.get(0).getTimeAttributeElement()).isAfter(firstOperationTime); + assertThat(nestedRecordList.get(1).getTimeAttributeElement()).isAfter(firstOperationTime); } @Test @@ -258,15 +402,59 @@ public void when_updatingNestedObjectToEmptyWithSingleLevel_existingInformationI NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setNestedRecord(updatedNestedRecord); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord); - mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - assertThat(persistedRecord.getNestedRecord()).isNull(); + assertThat(persistedRecord.getNestedRecord()).isNotNull(); + assertThat(persistedRecord.getNestedRecord().getId()).isNull(); + assertThat(persistedRecord.getNestedRecord().getNestedCounter()).isNull(); + assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isNull(); + assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isNotNull(); + } + + @Test + public void when_updatingNestedObjectWithSingleLevel_updateBehaviorIsChecked_scalar_only_update() { + Instant currentTime = Instant.now().minusMillis(1); + NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); + + RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); + record.setId("id123"); + record.setNestedRecord(nestedRecord); + + mappedTable.putItem(record); + + RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + Instant nestedCreatedTime = persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute(); + Instant nestedUpdatedTime = persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute(); + assertThat(nestedCreatedTime).isAfter(currentTime); + assertThat(nestedUpdatedTime).isEqualTo(nestedCreatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo(TEST_BEHAVIOUR_ATTRIBUTE); + + NestedRecordWithUpdateBehavior updatedNestedRecord = new NestedRecordWithUpdateBehavior(); + long updatedNestedCounter = 10L; + updatedNestedRecord.setNestedCounter(updatedNestedCounter); + updatedNestedRecord.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE + "updated"); + + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord); + + mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + + persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); + + //WRITE_IF_NOT_EXISTS detected on createdTimeAttribute and updateBehaviorAttribute -> not changed + assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isEqualTo(nestedCreatedTime); + assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo(TEST_BEHAVIOUR_ATTRIBUTE); + + assertThat(persistedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfter(nestedUpdatedTime); } private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long counter) { @@ -274,7 +462,6 @@ private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long nestedRecordWithDefaults.setId(id); nestedRecordWithDefaults.setNestedCounter(counter); nestedRecordWithDefaults.setNestedUpdateBehaviorAttribute(TEST_BEHAVIOUR_ATTRIBUTE); - nestedRecordWithDefaults.setNestedTimeAttribute(INSTANT_1); return nestedRecordWithDefaults; } @@ -282,31 +469,34 @@ private NestedRecordWithUpdateBehavior createNestedWithDefaults(String id, Long private void verifyMultipleLevelNestingTargetedUpdateBehavior(NestedRecordWithUpdateBehavior nestedRecord, long updatedOuterNestedCounter, long updatedInnerNestedCounter, - String test_behav_attribute, - Instant expected_time) { + String testBehaviorAttribute, + Instant expectedTime) { assertThat(nestedRecord).isNotNull(); assertThat(nestedRecord.getNestedRecord()).isNotNull(); assertThat(nestedRecord.getNestedCounter()).isEqualTo(updatedOuterNestedCounter); + assertThat(nestedRecord.getNestedCreatedTimeAttribute()).isAfter(expectedTime); + assertThat(nestedRecord.getNestedUpdatedTimeAttribute()).isAfter(expectedTime); assertThat(nestedRecord.getNestedRecord()).isNotNull(); assertThat(nestedRecord.getNestedRecord().getNestedCounter()).isEqualTo(updatedInnerNestedCounter); assertThat(nestedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isEqualTo( - test_behav_attribute); - assertThat(nestedRecord.getNestedRecord().getNestedTimeAttribute()).isEqualTo(expected_time); + testBehaviorAttribute); + assertThat(nestedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isAfter(expectedTime); + assertThat(nestedRecord.getNestedRecord().getNestedUpdatedTimeAttribute()).isAfter(expectedTime); } private void verifySingleLevelNestingTargetedUpdateBehavior(NestedRecordWithUpdateBehavior nestedRecord, - long updatedNestedCounter, String expected_behav_attr, - Instant expected_time) { + long updatedNestedCounter, String expectedBehaviorAttr, + Instant expectedTime) { assertThat(nestedRecord).isNotNull(); assertThat(nestedRecord.getNestedCounter()).isEqualTo(updatedNestedCounter); - assertThat(nestedRecord.getNestedUpdateBehaviorAttribute()).isEqualTo(expected_behav_attr); - assertThat(nestedRecord.getNestedTimeAttribute()).isEqualTo(expected_time); + assertThat(nestedRecord.getNestedUpdateBehaviorAttribute()).isEqualTo(expectedBehaviorAttr); + assertThat(nestedRecord.getNestedCreatedTimeAttribute()).isAfter(expectedTime); + assertThat(nestedRecord.getNestedUpdatedTimeAttribute()).isAfter(expectedTime); } @Test public void when_updatingNestedObjectWithMultipleLevels_inScalarOnlyMode_existingInformationIsPreserved() { - NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L); NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L); @@ -327,12 +517,12 @@ public void when_updatingNestedObjectWithMultipleLevels_inScalarOnlyMode_existin long outerNestedCounter = 200L; updatedNestedRecord1.setNestedCounter(outerNestedCounter); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setNestedRecord(updatedNestedRecord1); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord1); - mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); @@ -342,7 +532,6 @@ public void when_updatingNestedObjectWithMultipleLevels_inScalarOnlyMode_existin @Test public void when_updatingNestedObjectWithMultipleLevels_inMapsOnlyMode_existingInformationIsPreserved() { - NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L); NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L); @@ -358,12 +547,12 @@ public void when_updatingNestedObjectWithMultipleLevels_inMapsOnlyMode_existingI long outerNestedCounter = 200L; updatedNestedRecord1.setNestedCounter(outerNestedCounter); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setNestedRecord(updatedNestedRecord1); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord1); - mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY)); + mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY)); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); @@ -373,7 +562,7 @@ public void when_updatingNestedObjectWithMultipleLevels_inMapsOnlyMode_existingI @Test public void when_updatingNestedObjectWithMultipleLevels_default_mode_existingInformationIsErased() { - + Instant currentTime = Instant.now().minusMillis(1); NestedRecordWithUpdateBehavior nestedRecord1 = createNestedWithDefaults("id789", 50L); NestedRecordWithUpdateBehavior nestedRecord2 = createNestedWithDefaults("id456", 0L); @@ -394,22 +583,22 @@ public void when_updatingNestedObjectWithMultipleLevels_default_mode_existingInf long outerNestedCounter = 200L; updatedNestedRecord1.setNestedCounter(outerNestedCounter); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setNestedRecord(updatedNestedRecord1); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setNestedRecord(updatedNestedRecord1); - mappedTable.updateItem(r -> r.item(update_record)); + mappedTable.updateItem(r -> r.item(updateRecord)); RecordWithUpdateBehaviors persistedRecord = mappedTable.getItem(r -> r.key(k -> k.partitionValue("id123"))); - verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, innerNestedCounter, null, - null); + verifyMultipleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), outerNestedCounter, + innerNestedCounter, null, + currentTime); } @Test public void when_updatingNestedNonScalarObject_scalar_only_update_throwsDynamoDBException() { - NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); nestedRecord.setAttribute(TEST_ATTRIBUTE); @@ -418,35 +607,34 @@ public void when_updatingNestedNonScalarObject_scalar_only_update_throwsDynamoDB mappedTable.putItem(record); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setKey("abc"); - update_record.setNestedRecord(nestedRecord); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setKey("abc"); + updateRecord.setNestedRecord(nestedRecord); - assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY))) + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY))) .isInstanceOf(DynamoDbException.class); } @Test public void when_updatingNestedMap_mapsOnlyMode_newMapIsCreatedAndStored() { - RecordWithUpdateBehaviors record = new RecordWithUpdateBehaviors(); record.setId("id123"); mappedTable.putItem(record); - RecordWithUpdateBehaviors update_record = new RecordWithUpdateBehaviors(); - update_record.setId("id123"); - update_record.setVersion(1L); - update_record.setKey("abc"); + RecordWithUpdateBehaviors updateRecord = new RecordWithUpdateBehaviors(); + updateRecord.setId("id123"); + updateRecord.setVersion(1L); + updateRecord.setKey("abc"); NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id456", 5L); nestedRecord.setAttribute(TEST_ATTRIBUTE); - update_record.setNestedRecord(nestedRecord); + updateRecord.setNestedRecord(nestedRecord); RecordWithUpdateBehaviors persistedRecord = - mappedTable.updateItem(r -> r.item(update_record).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY)); + mappedTable.updateItem(r -> r.item(updateRecord).ignoreNullsMode(IgnoreNullsMode.MAPS_ONLY)); verifySingleLevelNestingTargetedUpdateBehavior(persistedRecord.getNestedRecord(), 5L, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); @@ -470,21 +658,20 @@ public void when_emptyNestedRecordIsSet_emptyMapIsStoredInTable() { .build()); assertThat(getItemResponse.item().get("nestedRecord")).isNotNull(); - assertThat(getItemResponse.item().get("nestedRecord").toString()).isEqualTo("AttributeValue(M={nestedTimeAttribute" - + "=AttributeValue(NUL=true), " - + "nestedRecord=AttributeValue(NUL=true), " - + "attribute=AttributeValue(NUL=true), " - + "id=AttributeValue(NUL=true), " - + "nestedUpdateBehaviorAttribute=AttributeValue" - + "(NUL=true), nestedCounter=AttributeValue" - + "(NUL=true), nestedVersionedAttribute" - + "=AttributeValue(NUL=true)})"); + Map nestedRecord = getItemResponse.item().get("nestedRecord").m(); + assertThat(nestedRecord.get("nestedCreatedTimeAttribute")).isNotNull(); + assertThat(nestedRecord.get("nestedUpdatedTimeAttribute")).isNotNull(); + assertTrue(nestedRecord.get("id").nul()); + assertTrue(nestedRecord.get("nestedRecord").nul()); + assertTrue(nestedRecord.get("attribute").nul()); + assertTrue(nestedRecord.get("nestedUpdateBehaviorAttribute").nul()); + assertTrue(nestedRecord.get("nestedCounter").nul()); + assertTrue(nestedRecord.get("nestedVersionedAttribute").nul()); } @Test public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformationIsPreserved_scalar_only_update() { - NestedRecordWithUpdateBehavior nestedRecord = createNestedWithDefaults("id123", 10L); CompositeRecord compositeRecord = new CompositeRecord(); @@ -493,15 +680,15 @@ public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformatio FlattenRecord flattenRecord = new FlattenRecord(); flattenRecord.setCompositeRecord(compositeRecord); flattenRecord.setId("id456"); - + flattenedMappedTable.putItem(r -> r.item(flattenRecord)); - + NestedRecordWithUpdateBehavior updateNestedRecord = new NestedRecordWithUpdateBehavior(); updateNestedRecord.setNestedCounter(100L); - + CompositeRecord updateCompositeRecord = new CompositeRecord(); updateCompositeRecord.setNestedRecord(updateNestedRecord); - + FlattenRecord updatedFlattenRecord = new FlattenRecord(); updatedFlattenRecord.setId("id456"); updatedFlattenRecord.setCompositeRecord(updateCompositeRecord); @@ -514,11 +701,8 @@ public void when_updatingNestedObjectWithSingleLevelFlattened_existingInformatio TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); } - - @Test public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformationIsPreserved_scalar_only_update() { - NestedRecordWithUpdateBehavior outerNestedRecord = createNestedWithDefaults("id123", 10L); NestedRecordWithUpdateBehavior innerNestedRecord = createNestedWithDefaults("id456", 5L); outerNestedRecord.setNestedRecord(innerNestedRecord); @@ -529,36 +713,37 @@ public void when_updatingNestedObjectWithMultipleLevelFlattened_existingInformat FlattenRecord flattenRecord = new FlattenRecord(); flattenRecord.setCompositeRecord(compositeRecord); flattenRecord.setId("id789"); - + flattenedMappedTable.putItem(r -> r.item(flattenRecord)); - + NestedRecordWithUpdateBehavior updateOuterNestedRecord = new NestedRecordWithUpdateBehavior(); updateOuterNestedRecord.setNestedCounter(100L); - + NestedRecordWithUpdateBehavior updateInnerNestedRecord = new NestedRecordWithUpdateBehavior(); updateInnerNestedRecord.setNestedCounter(50L); - + updateOuterNestedRecord.setNestedRecord(updateInnerNestedRecord); - + CompositeRecord updateCompositeRecord = new CompositeRecord(); updateCompositeRecord.setNestedRecord(updateOuterNestedRecord); - + FlattenRecord updateFlattenRecord = new FlattenRecord(); updateFlattenRecord.setCompositeRecord(updateCompositeRecord); updateFlattenRecord.setId("id789"); - + FlattenRecord persistedFlattenedRecord = flattenedMappedTable.updateItem(r -> r.item(updateFlattenRecord).ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); - + assertThat(persistedFlattenedRecord.getCompositeRecord()).isNotNull(); verifyMultipleLevelNestingTargetedUpdateBehavior(persistedFlattenedRecord.getCompositeRecord().getNestedRecord(), 100L, 50L, TEST_BEHAVIOUR_ATTRIBUTE, INSTANT_1); assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedCounter()).isEqualTo(100L); assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedRecord().getNestedCounter()).isEqualTo(50L); + assertThat(persistedFlattenedRecord.getCompositeRecord().getNestedRecord().getNestedRecord().getNestedCreatedTimeAttribute()).isNotNull(); } /** - * Currently, nested records are not updated through extensions. + * Currently, nested records are not updated through extensions (only the timestamp). */ @Test public void updateBehaviors_nested() { @@ -579,6 +764,40 @@ public void updateBehaviors_nested() { assertThat(persistedRecord.getNestedRecord().getNestedVersionedAttribute()).isNull(); assertThat(persistedRecord.getNestedRecord().getNestedCounter()).isNull(); assertThat(persistedRecord.getNestedRecord().getNestedUpdateBehaviorAttribute()).isNull(); - assertThat(persistedRecord.getNestedRecord().getNestedTimeAttribute()).isNull(); + assertThat(persistedRecord.getNestedRecord().getNestedCreatedTimeAttribute()).isNotNull(); + } + + @Test + public void updateBehaviour_onItemWithRootAttributeNameContainingReservedMarker_throwsException() { + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Attribute name 'attr_NESTED_ATTR_UPDATE_' contains reserved marker " + + "'_NESTED_ATTR_UPDATE_' and is not allowed."); + + BeanWithInvalidRootAttributeName record = new BeanWithInvalidRootAttributeName(); + record.setId("1"); + record.setAttr_NESTED_ATTR_UPDATE_(INSTANT_1); + + beanWithInvalidRootAttrNameMappedTable.updateItem(r -> r.item(record) + .ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); + } + + @Test + public void updateBehaviour_onItemWithNestedAttributeNameContainingReservedMarker_throwsException() { + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Attribute name 'childAttr_NESTED_ATTR_UPDATE_' contains reserved marker " + + "'_NESTED_ATTR_UPDATE_' and is not allowed."); + + BeanWithInvalidNestedAttributeName record = new BeanWithInvalidNestedAttributeName(); + record.setId("1"); + + ChildBeanWithInvalidAttributeName childBean = new ChildBeanWithInvalidAttributeName(); + childBean.setId("2"); + childBean.setChildAttr_NESTED_ATTR_UPDATE_(INSTANT_1); + record.setChild(childBean); + + beanWithInvalidNestedAttrNameMappedTable.updateItem(r -> r.item(record) + .ignoreNullsMode(IgnoreNullsMode.SCALAR_ONLY)); } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java index 883a89813c1a..17aca019b7a1 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/NestedRecordWithUpdateBehavior.java @@ -18,6 +18,7 @@ import static software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior.WRITE_IF_NOT_EXISTS; import java.time.Instant; +import java.util.List; import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAtomicCounter; import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute; import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; @@ -30,10 +31,12 @@ public class NestedRecordWithUpdateBehavior { private String id; private String nestedUpdateBehaviorAttribute; private Long nestedVersionedAttribute; - private Instant nestedTimeAttribute; + private Instant nestedCreatedTimeAttribute; + private Instant nestedUpdatedTimeAttribute; private Long nestedCounter; private NestedRecordWithUpdateBehavior nestedRecord; private String attribute; + private List nestedRecordList; @DynamoDbPartitionKey public String getId() { @@ -63,12 +66,22 @@ public void setNestedVersionedAttribute(Long nestedVersionedAttribute) { } @DynamoDbAutoGeneratedTimestampAttribute - public Instant getNestedTimeAttribute() { - return nestedTimeAttribute; + @DynamoDbUpdateBehavior(WRITE_IF_NOT_EXISTS) + public Instant getNestedCreatedTimeAttribute() { + return nestedCreatedTimeAttribute; + } + + public void setNestedCreatedTimeAttribute(Instant nestedCreatedTimeAttribute) { + this.nestedCreatedTimeAttribute = nestedCreatedTimeAttribute; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getNestedUpdatedTimeAttribute() { + return nestedUpdatedTimeAttribute; } - public void setNestedTimeAttribute(Instant nestedTimeAttribute) { - this.nestedTimeAttribute = nestedTimeAttribute; + public void setNestedUpdatedTimeAttribute(Instant nestedUpdatedTimeAttribute) { + this.nestedUpdatedTimeAttribute = nestedUpdatedTimeAttribute; } @DynamoDbAtomicCounter @@ -95,4 +108,43 @@ public String getAttribute() { public void setAttribute(String attribute) { this.attribute = attribute; } + + public List getNestedRecordList() { return nestedRecordList;} + + public void setNestedRecordList(List nestedRecordList) { + this.nestedRecordList = nestedRecordList; + } + + @DynamoDbBean + public static class TimestampListElement { + private String id; + private String attribute; + private Instant timeAttributeElement; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAttribute() { + return attribute; + } + + public void setAttribute(String attribute) { + this.attribute = attribute; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTimeAttributeElement() { + return timeAttributeElement; + } + + public void setTimeAttributeElement(Instant timeAttributeElement) { + this.timeAttributeElement = timeAttributeElement; + } + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java index 8bd874fee002..c6c248f3d2e4 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordWithUpdateBehaviors.java @@ -15,19 +15,21 @@ package software.amazon.awssdk.enhanced.dynamodb.functionaltests.models; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior.WRITE_IF_NOT_EXISTS; + import java.time.Instant; +import java.util.List; import software.amazon.awssdk.enhanced.dynamodb.converters.EpochMillisFormatTestConverter; import software.amazon.awssdk.enhanced.dynamodb.converters.TimeFormatUpdateTestConverter; import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute; import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbVersionAttribute; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.NestedRecordWithUpdateBehavior.TimestampListElement; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; -import static software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior.WRITE_IF_NOT_EXISTS; - @DynamoDbBean public class RecordWithUpdateBehaviors { private String id; @@ -40,6 +42,7 @@ public class RecordWithUpdateBehaviors { private Instant formattedLastAutoUpdatedOn; private NestedRecordWithUpdateBehavior nestedRecord; private String key; + private List nestedRecordList; @DynamoDbPartitionKey public String getId() { @@ -133,4 +136,10 @@ public NestedRecordWithUpdateBehavior getNestedRecord() { public void setNestedRecord(NestedRecordWithUpdateBehavior nestedRecord) { this.nestedRecord = nestedRecord; } + + public List getNestedRecordList() { return nestedRecordList;} + + public void setNestedRecordList(List nestedRecordList) { + this.nestedRecordList = nestedRecordList; + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java new file mode 100644 index 000000000000..a573b1897f0e --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/UpdateBehaviorTestModels.java @@ -0,0 +1,901 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests.models; + +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.updateBehavior; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; + +/** + * Test models for update behavior functionality testing across 4 schema types: Bean, Static, Immutable, and StaticImmutable. + * Provides simple/nested model variants, each containing writeAlwaysField (WRITE_ALWAYS) and writeOnceField (WRITE_IF_NOT_EXISTS) + * attributes at root and nested levels. Used by NestedUpdateBehaviorTest to validate @DynamoDbUpdateBehavior annotations. + */ +public final class UpdateBehaviorTestModels { + + private UpdateBehaviorTestModels() { + } + + @DynamoDbBean + public static class SimpleBean { + private String id; + private String writeAlwaysField; + private String writeOnceField; + private List childList; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public SimpleBean setId(String id) { + this.id = id; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getWriteAlwaysField() { + return writeAlwaysField; + } + + public SimpleBean setWriteAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getWriteOnceField() { + return writeOnceField; + } + + public SimpleBean setWriteOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; + return this; + } + + public List getChildList() { + return childList == null ? null : Collections.unmodifiableList(childList); + } + + public SimpleBean setChildList(List childList) { + this.childList = childList == null ? null : Collections.unmodifiableList(childList); + return this; + } + } + + @DynamoDbBean + public static class NestedBean { + private String id; + private String writeAlwaysField; + private String writeOnceField; + private NestedBeanChild child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public NestedBean setId(String id) { + this.id = id; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getWriteAlwaysField() { + return writeAlwaysField; + } + + public NestedBean setWriteAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getWriteOnceField() { + return writeOnceField; + } + + public NestedBean setWriteOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; + return this; + } + + public NestedBeanChild getChild() { + return child; + } + + public NestedBean setChild(NestedBeanChild child) { + this.child = child; + return this; + } + } + + @DynamoDbBean + public static class SimpleBeanChild { + private String id; + private String childAlwaysUpdate; + private String childWriteOnce; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public SimpleBeanChild setId(String id) { + this.id = id; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; + } + + public SimpleBeanChild setChildAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getChildWriteOnce() { + return childWriteOnce; + } + + public SimpleBeanChild setChildWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; + return this; + } + } + + @DynamoDbBean + public static class NestedBeanChild { + private String childAlwaysUpdate; + private String childWriteOnce; + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; + } + + public NestedBeanChild setChildAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getChildWriteOnce() { + return childWriteOnce; + } + + public NestedBeanChild setChildWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; + return this; + } + } + + @DynamoDbImmutable(builder = SimpleImmutableRecord.Builder.class) + public static final class SimpleImmutableRecord { + private final String id; + private final String writeAlwaysField; + private final String writeOnceField; + private final List childList; + + private SimpleImmutableRecord(Builder b) { + this.id = b.id; + this.writeAlwaysField = b.writeAlwaysField; + this.writeOnceField = b.writeOnceField; + this.childList = b.childList; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getWriteAlwaysField() { + return writeAlwaysField; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getWriteOnceField() { + return writeOnceField; + } + + public List getChildList() { + return childList == null ? null : Collections.unmodifiableList(childList); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private String writeAlwaysField; + private String writeOnceField; + private List childList; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder writeAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; + return this; + } + + public Builder writeOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; + return this; + } + + public Builder childList(List childList) { + this.childList = childList == null ? null : Collections.unmodifiableList(childList); + return this; + } + + public SimpleImmutableRecord build() { + return new SimpleImmutableRecord(this); + } + } + } + + @DynamoDbImmutable(builder = NestedImmutableRecord.Builder.class) + public static final class NestedImmutableRecord { + private final String id; + private final String writeAlwaysField; + private final String writeOnceField; + private final NestedImmutableChild child; + + private NestedImmutableRecord(Builder b) { + this.id = b.id; + this.writeAlwaysField = b.writeAlwaysField; + this.writeOnceField = b.writeOnceField; + this.child = b.child; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getWriteAlwaysField() { + return writeAlwaysField; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getWriteOnceField() { + return writeOnceField; + } + + public NestedImmutableChild getChild() { + return child; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private String writeAlwaysField; + private String writeOnceField; + private NestedImmutableChild child; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder writeAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; + return this; + } + + public Builder writeOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; + return this; + } + + public Builder child(NestedImmutableChild child) { + this.child = child; + return this; + } + + public NestedImmutableRecord build() { + return new NestedImmutableRecord(this); + } + } + } + + @DynamoDbImmutable(builder = SimpleImmutableChild.Builder.class) + public static final class SimpleImmutableChild { + private final String id; + private final String childAlwaysUpdate; + private final String childWriteOnce; + + private SimpleImmutableChild(Builder b) { + this.id = b.id; + this.childAlwaysUpdate = b.childAlwaysUpdate; + this.childWriteOnce = b.childWriteOnce; + } + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getChildWriteOnce() { + return childWriteOnce; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private String childAlwaysUpdate; + private String childWriteOnce; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder childAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; + return this; + } + + public Builder childWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; + return this; + } + + public SimpleImmutableChild build() { + return new SimpleImmutableChild(this); + } + } + } + + @DynamoDbImmutable(builder = NestedImmutableChild.Builder.class) + public static final class NestedImmutableChild { + private final String childAlwaysUpdate; + private final String childWriteOnce; + + private NestedImmutableChild(Builder b) { + this.childAlwaysUpdate = b.childAlwaysUpdate; + this.childWriteOnce = b.childWriteOnce; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS) + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public String getChildWriteOnce() { + return childWriteOnce; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String childAlwaysUpdate; + private String childWriteOnce; + + public Builder childAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; + return this; + } + + public Builder childWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; + return this; + } + + public NestedImmutableChild build() { + return new NestedImmutableChild(this); + } + } + } + + public static class SimpleStaticRecord { + private String id; + private String writeAlwaysField; + private String writeOnceField; + + public String getId() { + return id; + } + + public SimpleStaticRecord setId(String id) { + this.id = id; + return this; + } + + public String getWriteAlwaysField() { + return writeAlwaysField; + } + + public SimpleStaticRecord setWriteAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; + return this; + } + + public String getWriteOnceField() { + return writeOnceField; + } + + public SimpleStaticRecord setWriteOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; + return this; + } + } + + public static class NestedStaticRecord { + private String id; + private String writeAlwaysField; + private String writeOnceField; + private NestedStaticChildRecord child; + + public String getId() { + return id; + } + + public NestedStaticRecord setId(String id) { + this.id = id; + return this; + } + + public String getWriteAlwaysField() { + return writeAlwaysField; + } + + public NestedStaticRecord setWriteAlwaysField(String writeAlwaysField) { + this.writeAlwaysField = writeAlwaysField; + return this; + } + + public String getWriteOnceField() { + return writeOnceField; + } + + public NestedStaticRecord setWriteOnceField(String writeOnceField) { + this.writeOnceField = writeOnceField; + return this; + } + + public NestedStaticChildRecord getChild() { + return child; + } + + public NestedStaticRecord setChild(NestedStaticChildRecord child) { + this.child = child; + return this; + } + } + + public static class NestedStaticChildRecord { + private String childAlwaysUpdate; + private String childWriteOnce; + + public String getChildAlwaysUpdate() { + return childAlwaysUpdate; + } + + public NestedStaticChildRecord setChildAlwaysUpdate(String childAlwaysUpdate) { + this.childAlwaysUpdate = childAlwaysUpdate; + return this; + } + + public String getChildWriteOnce() { + return childWriteOnce; + } + + public NestedStaticChildRecord setChildWriteOnce(String childWriteOnce) { + this.childWriteOnce = childWriteOnce; + return this; + } + } + + public static TableSchema buildStaticSchemaForSimpleRecord() { + return StaticTableSchema + .builder(SimpleStaticRecord.class) + .newItemSupplier(SimpleStaticRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleStaticRecord::getId) + .setter(SimpleStaticRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("writeAlwaysField") + .getter(SimpleStaticRecord::getWriteAlwaysField) + .setter(SimpleStaticRecord::setWriteAlwaysField) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name("writeOnceField") + .getter(SimpleStaticRecord::getWriteOnceField) + .setter(SimpleStaticRecord::setWriteOnceField) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .build(); + } + + public static TableSchema buildStaticSchemaForNestedRecord() { + TableSchema childSchema = StaticTableSchema + .builder(NestedStaticChildRecord.class) + .newItemSupplier(NestedStaticChildRecord::new) + .addAttribute(String.class, a -> a.name("childAlwaysUpdate") + .getter(NestedStaticChildRecord::getChildAlwaysUpdate) + .setter(NestedStaticChildRecord::setChildAlwaysUpdate) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name("childWriteOnce") + .getter(NestedStaticChildRecord::getChildWriteOnce) + .setter(NestedStaticChildRecord::setChildWriteOnce) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .build(); + + return StaticTableSchema + .builder(NestedStaticRecord.class) + .newItemSupplier(NestedStaticRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedStaticRecord::getId) + .setter(NestedStaticRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("writeAlwaysField") + .getter(NestedStaticRecord::getWriteAlwaysField) + .setter(NestedStaticRecord::setWriteAlwaysField) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name("writeOnceField") + .getter(NestedStaticRecord::getWriteOnceField) + .setter(NestedStaticRecord::setWriteOnceField) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(EnhancedType.documentOf(NestedStaticChildRecord.class, childSchema), + a -> a.name("child") + .getter(NestedStaticRecord::getChild) + .setter(NestedStaticRecord::setChild)) + .build(); + } + + public static TableSchema buildStaticSchemaForNestedRecord_NoChildSchemaDefined() { + TableSchema childSchema = null; + + return StaticTableSchema + .builder(NestedStaticRecord.class) + .newItemSupplier(NestedStaticRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedStaticRecord::getId) + .setter(NestedStaticRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("writeAlwaysField") + .getter(NestedStaticRecord::getWriteAlwaysField) + .setter(NestedStaticRecord::setWriteAlwaysField) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name("writeOnceField") + .getter(NestedStaticRecord::getWriteOnceField) + .setter(NestedStaticRecord::setWriteOnceField) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(EnhancedType.documentOf(NestedStaticChildRecord.class, childSchema), + a -> a.name("child") + .getter(NestedStaticRecord::getChild) + .setter(NestedStaticRecord::setChild)) + .build(); + } + + public static TableSchema buildStaticImmutableSchemaForSimpleRecord() { + TableSchema childSchema = StaticImmutableTableSchema + .builder(SimpleImmutableChild.class, SimpleImmutableChild.Builder.class) + .newItemBuilder(SimpleImmutableChild::builder, SimpleImmutableChild.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleImmutableChild::getId) + .setter(SimpleImmutableChild.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name( + "childAlwaysUpdate") + .getter(SimpleImmutableChild::getChildAlwaysUpdate) + .setter(SimpleImmutableChild.Builder::childAlwaysUpdate) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name( + "childWriteOnce") + .getter(SimpleImmutableChild::getChildWriteOnce) + .setter(SimpleImmutableChild.Builder::childWriteOnce) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .build(); + + return StaticImmutableTableSchema + .builder(SimpleImmutableRecord.class, SimpleImmutableRecord.Builder.class) + .newItemBuilder(SimpleImmutableRecord::builder, SimpleImmutableRecord.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(SimpleImmutableRecord::getId) + .setter(SimpleImmutableRecord.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("writeAlwaysField") + .getter(SimpleImmutableRecord::getWriteAlwaysField) + .setter(SimpleImmutableRecord.Builder::writeAlwaysField) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name("writeOnceField") + .getter(SimpleImmutableRecord::getWriteOnceField) + .setter(SimpleImmutableRecord.Builder::writeOnceField) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(EnhancedType.listOf(EnhancedType.documentOf(SimpleImmutableChild.class, + childSchema)), + a -> a.name("childList") + .getter(SimpleImmutableRecord::getChildList) + .setter(SimpleImmutableRecord.Builder::childList)) + .build(); + } + + public static TableSchema buildStaticImmutableSchemaForNestedRecord() { + TableSchema childSchema = StaticImmutableTableSchema + .builder(NestedImmutableChild.class, NestedImmutableChild.Builder.class) + .newItemBuilder(NestedImmutableChild::builder, NestedImmutableChild.Builder::build) + .addAttribute(String.class, a -> a.name("childAlwaysUpdate") + .getter(NestedImmutableChild::getChildAlwaysUpdate) + .setter(NestedImmutableChild.Builder::childAlwaysUpdate) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name("childWriteOnce") + .getter(NestedImmutableChild::getChildWriteOnce) + .setter(NestedImmutableChild.Builder::childWriteOnce) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .build(); + + return StaticImmutableTableSchema + .builder(NestedImmutableRecord.class, NestedImmutableRecord.Builder.class) + .newItemBuilder(NestedImmutableRecord::builder, NestedImmutableRecord.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedImmutableRecord::getId) + .setter(NestedImmutableRecord.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("writeAlwaysField") + .getter(NestedImmutableRecord::getWriteAlwaysField) + .setter(NestedImmutableRecord.Builder::writeAlwaysField) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name("writeOnceField") + .getter(NestedImmutableRecord::getWriteOnceField) + .setter(NestedImmutableRecord.Builder::writeOnceField) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(EnhancedType.documentOf(NestedImmutableChild.class, childSchema), + a -> a.name("child") + .getter(NestedImmutableRecord::getChild) + .setter(NestedImmutableRecord.Builder::child)) + .build(); + } + + public static TableSchema buildStaticImmutableSchema_NoChildSchemaDefined() { + TableSchema childSchema = null; + + return StaticImmutableTableSchema + .builder(NestedImmutableRecord.class, NestedImmutableRecord.Builder.class) + .newItemBuilder(NestedImmutableRecord::builder, NestedImmutableRecord.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(NestedImmutableRecord::getId) + .setter(NestedImmutableRecord.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("writeAlwaysField") + .getter(NestedImmutableRecord::getWriteAlwaysField) + .setter(NestedImmutableRecord.Builder::writeAlwaysField) + .tags(updateBehavior(UpdateBehavior.WRITE_ALWAYS))) + .addAttribute(String.class, a -> a.name("writeOnceField") + .getter(NestedImmutableRecord::getWriteOnceField) + .setter(NestedImmutableRecord.Builder::writeOnceField) + .tags(updateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS))) + .addAttribute(EnhancedType.documentOf(NestedImmutableChild.class, childSchema), + a -> a.name("child") + .getter(NestedImmutableRecord::getChild) + .setter(NestedImmutableRecord.Builder::child)) + .build(); + } + + /** + * Creates a SimpleBean with initial values for testing update behavior. + */ + public static SimpleBean createSimpleBean() { + return new SimpleBean() + .setId("1") + .setWriteAlwaysField("initial_writeAlways") + .setWriteOnceField("initial_writeOnce") + .setChildList(Arrays.asList( + new SimpleBeanChild().setId("child1") + .setChildAlwaysUpdate("child1_initial_writeAlways") + .setChildWriteOnce("child1_initial_writeOnce"), + new SimpleBeanChild().setId("child2") + .setChildAlwaysUpdate("child2_initial_writeAlways") + .setChildWriteOnce("child2_initial_writeOnce"))); + } + + /** + * Creates a NestedBean with initial values for testing update behavior. + */ + public static NestedBean createNestedBean() { + return new NestedBean() + .setId("1") + .setWriteAlwaysField("initial_writeAlways") + .setWriteOnceField("initial_writeOnce") + .setChild(new NestedBeanChild() + .setChildAlwaysUpdate("child_initial_writeAlways") + .setChildWriteOnce("child_initial_writeOnce")); + } + + /** + * Creates a SimpleImmutableRecord with initial values for testing update behavior. + */ + public static SimpleImmutableRecord createSimpleImmutableRecord() { + return SimpleImmutableRecord + .builder() + .id("1") + .writeAlwaysField("initial_writeAlways") + .writeOnceField("initial_writeOnce") + .childList(Arrays.asList( + SimpleImmutableChild.builder() + .id("child1") + .childAlwaysUpdate("child1_initial_writeAlways") + .childWriteOnce("child1_initial_writeOnce") + .build(), + SimpleImmutableChild.builder() + .id("child2") + .childAlwaysUpdate("child2_initial_writeAlways") + .childWriteOnce("child2_initial_writeOnce") + .build())) + .build(); + } + + /** + * Creates a NestedImmutableRecord with initial values for testing update behavior. + */ + public static NestedImmutableRecord createNestedImmutableRecord() { + return NestedImmutableRecord + .builder() + .id("1") + .writeAlwaysField("initial_writeAlways") + .writeOnceField("initial_writeOnce") + .child(NestedImmutableChild + .builder() + .childAlwaysUpdate("child_initial_writeAlways") + .childWriteOnce("child_initial_writeOnce") + .build()) + .build(); + } + + /** + * Creates a SimpleStaticRecord with initial values for testing update behavior. + */ + public static SimpleStaticRecord createSimpleStaticRecord() { + return new SimpleStaticRecord() + .setId("1") + .setWriteAlwaysField("initial_writeAlways") + .setWriteOnceField("initial_writeOnce"); + } + + /** + * Creates a NestedStaticRecord with initial values for testing update behavior. + */ + public static NestedStaticRecord createNestedStaticRecord() { + return new NestedStaticRecord() + .setId("1") + .setWriteAlwaysField("initial_writeAlways") + .setWriteOnceField("initial_writeOnce") + .setChild(new NestedStaticChildRecord() + .setChildAlwaysUpdate("child_initial_writeAlways") + .setChildWriteOnce("child_initial_writeOnce")); + } + + /** + * Test model with an invalid root-level attribute name containing the reserved '_NESTED_ATTR_UPDATE_' pattern. Used to test + * validation of attribute names that conflict with internal DynamoDB Enhanced Client conventions. + */ + @DynamoDbBean + public static class BeanWithInvalidRootAttributeName { + private String id; + private Instant attr_NESTED_ATTR_UPDATE_; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithInvalidRootAttributeName setId(String id) { + this.id = id; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public Instant getAttr_NESTED_ATTR_UPDATE_() { + return attr_NESTED_ATTR_UPDATE_; + } + + public BeanWithInvalidRootAttributeName setAttr_NESTED_ATTR_UPDATE_(Instant attr_NESTED_ATTR_UPDATE_) { + this.attr_NESTED_ATTR_UPDATE_ = attr_NESTED_ATTR_UPDATE_; + return this; + } + } + + /** + * Test model with an invalid nested attribute name containing the reserved '_NESTED_ATTR_UPDATE_' pattern. Used to test + * validation of nested attribute names that conflict with internal DynamoDB Enhanced Client conventions. + */ + @DynamoDbBean + public static class BeanWithInvalidNestedAttributeName { + private String id; + private ChildBeanWithInvalidAttributeName child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithInvalidNestedAttributeName setId(String id) { + this.id = id; + return this; + } + + public ChildBeanWithInvalidAttributeName getChild() { + return child; + } + + public BeanWithInvalidNestedAttributeName setChild(ChildBeanWithInvalidAttributeName child) { + this.child = child; + return this; + } + + @DynamoDbBean + public static class ChildBeanWithInvalidAttributeName { + private String id; + private Instant childAttr_NESTED_ATTR_UPDATE_; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public ChildBeanWithInvalidAttributeName setId(String id) { + this.id = id; + return this; + } + + @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) + public Instant getChildAttr_NESTED_ATTR_UPDATE_() { + return childAttr_NESTED_ATTR_UPDATE_; + } + + public ChildBeanWithInvalidAttributeName setChildAttr_NESTED_ATTR_UPDATE_(Instant childAttr_NESTED_ATTR_UPDATE_) { + this.childAttr_NESTED_ATTR_UPDATE_ = childAttr_NESTED_ATTR_UPDATE_; + return this; + } + } + } +} \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java index 6e3bbdbdc9ad..30a6e142eedf 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/EnhancedClientUtilsTest.java @@ -16,21 +16,82 @@ package software.amazon.awssdk.enhanced.dynamodb.internal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; -import org.junit.jupiter.api.Test; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +@RunWith(MockitoJUnitRunner.class) public class EnhancedClientUtilsTest { private static final AttributeValue PARTITION_VALUE = AttributeValue.builder().s("id123").build(); private static final AttributeValue SORT_VALUE = AttributeValue.builder().s("sort123").build(); + @Mock + private TableSchema 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 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")); + + } +}