diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-d40ed28.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-d40ed28.json new file mode 100644 index 000000000000..ccd9effaa8a4 --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-d40ed28.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Support update expressions in single request update" +} 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..bd8a15389362 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 @@ -17,12 +17,10 @@ import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.readAndTransformSingleItem; -import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.operationExpression; import static software.amazon.awssdk.utils.CollectionUtils.filterMap; import java.util.Collection; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -36,8 +34,10 @@ import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification; import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionResolver; import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode; import software.amazon.awssdk.enhanced.dynamodb.model.TransactUpdateItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse; import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; @@ -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(tableMetadata, transformation, nonKeyAttributes, request); Expression conditionExpression = generateConditionExpressionIfExist(transformation, request); Map expressionNames = coalesceExpressionNames(updateExpression, conditionExpression); @@ -271,27 +271,37 @@ public TransactWriteItem generateTransactWriteItem(TableSchema tableSchema, O } /** - * Retrieves the UpdateExpression from extensions if existing, and then creates an UpdateExpression for the request POJO - * 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. + * Combines POJO, extension, and request update expressions via {@link UpdateExpressionResolver}, honoring the request's + * {@link UpdateExpressionMergeStrategy}. For {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}, see + * {@link UpdateExpressionMergeStrategy} (path overlap resolution; request wins over extension over POJO). */ - private Expression generateUpdateExpressionIfExist(TableMetadata tableMetadata, - WriteModification transformation, - Map attributes) { - UpdateExpression updateExpression = null; - if (transformation != null && transformation.updateExpression() != null) { - updateExpression = transformation.updateExpression(); - } - if (!attributes.isEmpty()) { - List nonRemoveAttributes = UpdateExpressionConverter.findAttributeNames(updateExpression); - UpdateExpression operationUpdateExpression = operationExpression(attributes, tableMetadata, nonRemoveAttributes); - if (updateExpression == null) { - updateExpression = operationUpdateExpression; - } else { - updateExpression = UpdateExpression.mergeExpressions(updateExpression, operationUpdateExpression); - } - } - return UpdateExpressionConverter.toExpression(updateExpression); + private Expression generateUpdateExpressionIfExist( + TableMetadata tableMetadata, + WriteModification transformation, + Map attributes, + Either, TransactUpdateItemEnhancedRequest> request) { + + UpdateExpression requestUpdateExpression = + request.left().map(UpdateItemEnhancedRequest::updateExpression) + .orElseGet(() -> request.right().map(TransactUpdateItemEnhancedRequest::updateExpression).orElse(null)); + + UpdateExpressionMergeStrategy updateExpressionMergeStrategy = + request.left().map(UpdateItemEnhancedRequest::updateExpressionMergeStrategy) + .orElseGet(() -> request.right() + .map(TransactUpdateItemEnhancedRequest::updateExpressionMergeStrategy) + .orElse(UpdateExpressionMergeStrategy.LEGACY)); + + UpdateExpressionResolver updateExpressionResolver = + UpdateExpressionResolver.builder() + .tableMetadata(tableMetadata) + .nonKeyAttributes(attributes) + .requestExpression(requestUpdateExpression) + .updateExpressionMergeStrategy(updateExpressionMergeStrategy) + .extensionExpression(transformation != null ? transformation.updateExpression() : null) + .build(); + + UpdateExpression mergedUpdateExpression = updateExpressionResolver.resolve(); + return UpdateExpressionConverter.toExpression(mergedUpdateExpression); } /** diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverter.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverter.java index e756b4441f5d..2410ec432173 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverter.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverter.java @@ -93,14 +93,14 @@ public static Expression toExpression(UpdateExpression expression) { } /** - * Attempts to find the list of attribute names that will be updated for the supplied {@link UpdateExpression} by looking at + * Attempts to find the distinct attribute names that will be updated for the supplied {@link UpdateExpression} by looking at * the combined collection of paths and ExpressionName values. Because attribute names can be composed from nested * attribute references and list references, the leftmost part will be returned if composition is detected. *

* Examples: The expression contains a {@link DeleteAction} with a path value of 'MyAttribute[1]'; the list returned * will have 'MyAttribute' as an element.} * - * @return A list of top level attribute names that have update actions associated. + * @return A list of distinct top-level attribute names that have update actions associated. */ public static List findAttributeNames(UpdateExpression updateExpression) { if (updateExpression == null) { @@ -109,7 +109,7 @@ public static List findAttributeNames(UpdateExpression updateExpression) List attributeNames = listPathsWithoutTokens(updateExpression); List attributeNamesFromTokens = listAttributeNamesFromTokens(updateExpression); attributeNames.addAll(attributeNamesFromTokens); - return attributeNames; + return attributeNames.stream().distinct().collect(Collectors.toList()); } private static List groupExpressions(UpdateExpression expression) { diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolver.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolver.java new file mode 100644 index 000000000000..c459b2734fd1 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolver.java @@ -0,0 +1,289 @@ +/* + * 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 java.util.Objects.requireNonNull; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.attributesPresentInOtherExpressions; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.generateItemRemoveExpression; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.generateItemSetExpression; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.resolveDocumentPath; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy; +import software.amazon.awssdk.enhanced.dynamodb.update.AddAction; +import software.amazon.awssdk.enhanced.dynamodb.update.DeleteAction; +import software.amazon.awssdk.enhanced.dynamodb.update.RemoveAction; +import software.amazon.awssdk.enhanced.dynamodb.update.SetAction; +import software.amazon.awssdk.enhanced.dynamodb.update.UpdateAction; +import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.Validate; + +/** + * Builds one {@link UpdateExpression} from three places the enhanced client can get updates: + *

    + *
  • Non-key fields on the item (POJO) — turned into {@code SET} / {@code REMOVE} actions
  • + *
  • An optional expression from {@link software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension + * extensions}
  • + *
  • An optional {@link UpdateExpression} on the update request
  • + *
+ *

+ * How those pieces are combined is controlled by {@link UpdateExpressionMergeStrategy} on the request. This class applies + * that strategy so the result can be sent to DynamoDB as a single update expression. + * + * @see UpdateExpressionMergeStrategy + */ +@SdkInternalApi +public final class UpdateExpressionResolver { + + private final TableMetadata tableMetadata; + private final Map nonKeyAttributes; + private final UpdateExpression extensionExpression; + private final UpdateExpression requestExpression; + private final UpdateExpressionMergeStrategy updateExpressionMergeStrategy; + + private UpdateExpressionResolver(Builder builder) { + this.tableMetadata = builder.tableMetadata; + this.nonKeyAttributes = builder.nonKeyAttributes; + this.extensionExpression = builder.extensionExpression; + this.requestExpression = builder.requestExpression; + this.updateExpressionMergeStrategy = builder.updateExpressionMergeStrategy; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private TableMetadata tableMetadata; + private Map nonKeyAttributes = Collections.emptyMap(); + private UpdateExpression extensionExpression; + private UpdateExpression requestExpression; + private UpdateExpressionMergeStrategy updateExpressionMergeStrategy = UpdateExpressionMergeStrategy.LEGACY; + + /** + * Builds a {@link UpdateExpressionResolver}. When {@link #nonKeyAttributes(Map)} is non-empty, + * {@link #tableMetadata(TableMetadata)} is required so item {@code SET} and {@code REMOVE} actions can be generated. + * + * @return a new resolver instance + * @throws NullPointerException if {@code nonKeyAttributes} is non-empty and {@code tableMetadata} was never set + */ + public UpdateExpressionResolver build() { + if (!nonKeyAttributes.isEmpty()) { + Validate.paramNotNull(tableMetadata, "tableMetadata"); + } + return new UpdateExpressionResolver(this); + } + + public Builder tableMetadata(TableMetadata tableMetadata) { + this.tableMetadata = requireNonNull( + tableMetadata, "A TableMetadata is required when generating an Update Expression"); + return this; + } + + public Builder nonKeyAttributes(Map nonKeyAttributes) { + if (nonKeyAttributes == null) { + this.nonKeyAttributes = Collections.emptyMap(); + } else { + this.nonKeyAttributes = Collections.unmodifiableMap(new HashMap<>(nonKeyAttributes)); + } + return this; + } + + public Builder extensionExpression(UpdateExpression extensionExpression) { + this.extensionExpression = extensionExpression; + return this; + } + + public Builder requestExpression(UpdateExpression requestExpression) { + this.requestExpression = requestExpression; + return this; + } + + public Builder updateExpressionMergeStrategy(UpdateExpressionMergeStrategy updateExpressionMergeStrategy) { + this.updateExpressionMergeStrategy = updateExpressionMergeStrategy == null + ? UpdateExpressionMergeStrategy.LEGACY + : updateExpressionMergeStrategy; + return this; + } + } + + /** + * Returns a single merged {@link UpdateExpression} ready for DynamoDB, or {@code null} if there is nothing to update. + *

+ * What gets merged — If the builder supplied non-key attribute values, those are converted into item + * {@code SET} and {@code REMOVE} actions first. Those are then combined with the extension expression and the request + * expression (either may be absent). + *

+ * {@link UpdateExpressionMergeStrategy#LEGACY} (default) — All actions from all sources are chained together. + * If DynamoDB considers any two paths to overlap, the service rejects the request. One safeguard remains: a {@code REMOVE} + * for a {@code null} POJO field is skipped when that attribute name also appears in the extension or request expression. + *

+ * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE} — Actions are merged in three passes: keep + * every request action; keep extension actions that do not overlap any request path; keep item actions that do not overlap + * the request or any extension action that was kept. Full rules, examples, and the definition of path overlap are on + * {@link UpdateExpressionMergeStrategy}. + * + * @return the merged expression, or {@code null} if no actions remain + * @see UpdateExpressionMergeStrategy + */ + public UpdateExpression resolve() { + UpdateExpression itemExpression = null; + + if (!nonKeyAttributes.isEmpty()) { + Set attributesExcludedFromRemoval = attributesPresentInOtherExpressions( + Arrays.asList(extensionExpression, requestExpression)); + + itemExpression = UpdateExpression.mergeExpressions( + generateItemSetExpression(nonKeyAttributes, tableMetadata), + generateItemRemoveExpression(nonKeyAttributes, attributesExcludedFromRemoval)); + } + + if (updateExpressionMergeStrategy == UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE) { + return mergeWithPathPriority(itemExpression, extensionExpression, requestExpression); + } + + return Stream.of(itemExpression, extensionExpression, requestExpression) + .filter(Objects::nonNull) + .reduce(UpdateExpression::mergeExpressions) + .orElse(null); + } + + TableMetadata tableMetadata() { + return tableMetadata; + } + + Map nonKeyAttributes() { + return nonKeyAttributes; + } + + UpdateExpression extensionExpression() { + return extensionExpression; + } + + UpdateExpression requestExpression() { + return requestExpression; + } + + UpdateExpressionMergeStrategy updateExpressionMergeStrategy() { + return updateExpressionMergeStrategy; + } + + /** + * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}: merge request, then extension actions that do not conflict + * with the request, then item actions that do not conflict with the request or retained extension paths. + */ + private static UpdateExpression mergeWithPathPriority(UpdateExpression itemExpression, + UpdateExpression extensionExpression, + UpdateExpression requestExpression) { + Set requestResolvedPaths = resolvedPaths(requestExpression); + UpdateExpression extensionExpressionFiltered = + excludeOverlappingActions(extensionExpression, requestResolvedPaths); + + Set higherPriorityResolvedPaths = new HashSet<>(requestResolvedPaths); + higherPriorityResolvedPaths.addAll(resolvedPaths(extensionExpressionFiltered)); + UpdateExpression itemExpressionFiltered = + excludeOverlappingActions(itemExpression, higherPriorityResolvedPaths); + + return Stream.of(requestExpression, extensionExpressionFiltered, itemExpressionFiltered) + .filter(Objects::nonNull) + .reduce(UpdateExpression::mergeExpressions) + .orElse(null); + } + + private static Stream streamActions(UpdateExpression expression) { + return Stream.of(expression.setActions().stream(), + expression.removeActions().stream(), + expression.deleteActions().stream(), + expression.addActions().stream()) + .flatMap(Function.identity()); + } + + private static Set resolvedPaths(UpdateExpression expression) { + if (expression == null) { + return Collections.emptySet(); + } + return streamActions(expression) + .map(UpdateExpressionResolver::resolvePath) + .collect(Collectors.toCollection(HashSet::new)); + } + + /** + * Copy of {@code expression} without actions whose path overlaps any path in {@code higherPriorityResolvedPaths}. + * {@code null} if nothing remains; unchanged when the path set is empty (or when {@code expression} is {@code null}). + */ + private static UpdateExpression excludeOverlappingActions(UpdateExpression expression, + Set higherPriorityResolvedPaths) { + if (expression == null) { + return null; + } + if (higherPriorityResolvedPaths.isEmpty()) { + return expression; + } + List retained = streamActions(expression) + .filter(action -> !conflictsWith(resolvePath(action), higherPriorityResolvedPaths)) + .collect(Collectors.toList()); + + return retained.isEmpty() ? null : UpdateExpression.builder().actions(retained).build(); + } + + private static String resolvePath(UpdateAction action) { + if (action instanceof SetAction) { + SetAction a = (SetAction) action; + return resolveDocumentPath(a.path(), a.expressionNames()); + } + if (action instanceof RemoveAction) { + RemoveAction a = (RemoveAction) action; + return resolveDocumentPath(a.path(), a.expressionNames()); + } + if (action instanceof DeleteAction) { + DeleteAction a = (DeleteAction) action; + return resolveDocumentPath(a.path(), a.expressionNames()); + } + if (action instanceof AddAction) { + AddAction a = (AddAction) action; + return resolveDocumentPath(a.path(), a.expressionNames()); + } + throw new IllegalArgumentException("Unsupported UpdateAction: " + action.getClass()); + } + + /** + * Whether {@code candidatePath} overlaps any path in {@code higherPriorityPaths} in the DynamoDB document sense: same path, + * or one path is a prefix of the other at a {@code .} or {@code [} boundary. + */ + private static boolean conflictsWith(String candidatePath, Set higherPriorityPaths) { + return higherPriorityPaths.stream() + .anyMatch(higher -> + candidatePath.equals(higher) + || candidatePath.startsWith(higher + ".") + || candidatePath.startsWith(higher + "[") + || higher.startsWith(candidatePath + ".") + || higher.startsWith(candidatePath + "[")); + } +} 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..bf92fa5c5cba 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 @@ -22,9 +22,12 @@ import static software.amazon.awssdk.utils.CollectionUtils.filterMap; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -54,31 +57,34 @@ public static String ifNotExists(String key, String initValue) { } /** - * Generates an UpdateExpression representing a POJO, with only SET and REMOVE actions. + * SET actions for every {@code itemMap} entry that is not DynamoDB NULL. For each attribute, {@link UpdateBehavior} is + * resolved from {@code tableMetadata}. */ - public static UpdateExpression operationExpression(Map itemMap, - TableMetadata tableMetadata, - List nonRemoveAttributes) { - + static UpdateExpression generateItemSetExpression(Map itemMap, + TableMetadata tableMetadata) { Map setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue())); - UpdateExpression setAttributeExpression = UpdateExpression.builder() - .actions(setActionsFor(setAttributes, tableMetadata)) - .build(); - - Map removeAttributes = - filterMap(itemMap, e -> isNullAttributeValue(e.getValue()) && !nonRemoveAttributes.contains(e.getKey())); - - UpdateExpression removeAttributeExpression = UpdateExpression.builder() - .actions(removeActionsFor(removeAttributes)) - .build(); + return UpdateExpression.builder() + .actions(setActionsFor(setAttributes, tableMetadata)) + .build(); + } - return UpdateExpression.mergeExpressions(setAttributeExpression, removeAttributeExpression); + /** + * REMOVE actions for NULL-valued {@code itemMap} attributes, except names in {@code nonRemoveAttributes} (e.g. already + * updated elsewhere when merging expressions). + */ + static UpdateExpression generateItemRemoveExpression(Map itemMap, + Collection nonRemoveAttributes) { + Map removeAttributes = filterMap(itemMap, e -> isNullAttributeValue(e.getValue()) + && !nonRemoveAttributes.contains(e.getKey())); + return UpdateExpression.builder() + .actions(removeActionsFor(removeAttributes)) + .build(); } /** * Creates a list of SET actions for all attributes supplied in the map. */ - private static List setActionsFor(Map attributesToSet, TableMetadata tableMetadata) { + static List setActionsFor(Map attributesToSet, TableMetadata tableMetadata) { return attributesToSet.entrySet() .stream() .map(entry -> setValue(entry.getKey(), @@ -90,13 +96,39 @@ private static List setActionsFor(Map attribu /** * Creates a list of REMOVE actions for all attributes supplied in the map. */ - private static List removeActionsFor(Map attributesToSet) { + static List removeActionsFor(Map attributesToSet) { return attributesToSet.entrySet() .stream() .map(entry -> remove(entry.getKey())) .collect(Collectors.toList()); } + /** + * Distinct top-level names from non-null expressions (see {@link UpdateExpressionConverter#findAttributeNames}). Skips + * {@code null} elements; used to avoid REMOVE when those attributes are updated in other expressions. + */ + static Set attributesPresentInOtherExpressions(Collection updateExpressions) { + return updateExpressions.stream() + .filter(Objects::nonNull) + .map(UpdateExpressionConverter::findAttributeNames) + .flatMap(List::stream) + .collect(Collectors.toSet()); + } + + /** + * Resolves an update path by substituting expression attribute name placeholders (e.g. {@code #a} → logical name). The result + * is the full DynamoDB document path as used for overlap detection. + */ + static String resolveDocumentPath(String fullAttributePath, Map expressionNames) { + String resolvedPath = fullAttributePath; + Map names = expressionNames == null ? Collections.emptyMap() : expressionNames; + + for (Map.Entry entry : names.entrySet()) { + resolvedPath = resolvedPath.replace(entry.getKey(), entry.getValue()); + } + return resolvedPath; + } + /** * Creates a REMOVE action for an attribute, using a token as a placeholder for the attribute name. */ diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java index 4f163992f6e8..a1617bf00f31 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java @@ -22,6 +22,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; /** @@ -42,6 +43,8 @@ public class TransactUpdateItemEnhancedRequest { private final Boolean ignoreNulls; private final IgnoreNullsMode ignoreNullsMode; private final Expression conditionExpression; + private final UpdateExpression updateExpression; + private final UpdateExpressionMergeStrategy updateExpressionMergeStrategy; private final String returnValuesOnConditionCheckFailure; private TransactUpdateItemEnhancedRequest(Builder builder) { @@ -49,6 +52,8 @@ private TransactUpdateItemEnhancedRequest(Builder builder) { this.ignoreNulls = builder.ignoreNulls; this.ignoreNullsMode = builder.ignoreNullsMode; this.conditionExpression = builder.conditionExpression; + this.updateExpression = builder.updateExpression; + this.updateExpressionMergeStrategy = builder.updateExpressionMergeStrategy; this.returnValuesOnConditionCheckFailure = builder.returnValuesOnConditionCheckFailure; } @@ -71,6 +76,8 @@ public Builder toBuilder() { .ignoreNulls(ignoreNulls) .ignoreNullsMode(ignoreNullsMode) .conditionExpression(conditionExpression) + .updateExpression(updateExpression) + .updateExpressionMergeStrategy(updateExpressionMergeStrategy) .returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure); } @@ -104,6 +111,23 @@ public Expression conditionExpression() { return conditionExpression; } + /** + * Returns the update expression {@link UpdateExpression} set on this request object, or null if it doesn't exist. + */ + public UpdateExpression updateExpression() { + return updateExpression; + } + + /** + * Returns how POJO, extension, and request update actions are merged. Defaults to + * {@link UpdateExpressionMergeStrategy#LEGACY} when unset on the builder. + */ + public UpdateExpressionMergeStrategy updateExpressionMergeStrategy() { + return updateExpressionMergeStrategy == null + ? UpdateExpressionMergeStrategy.LEGACY + : updateExpressionMergeStrategy; + } + /** * Returns what values to return if the condition check fails. *

@@ -152,6 +176,12 @@ public boolean equals(Object o) { if (!Objects.equals(conditionExpression, that.conditionExpression)) { return false; } + if (!Objects.equals(updateExpression, that.updateExpression)) { + return false; + } + if (updateExpressionMergeStrategy != that.updateExpressionMergeStrategy) { + return false; + } return Objects.equals(returnValuesOnConditionCheckFailure, that.returnValuesOnConditionCheckFailure); } @@ -160,6 +190,8 @@ public int hashCode() { int result = Objects.hashCode(item); result = 31 * result + Objects.hashCode(ignoreNulls); result = 31 * result + Objects.hashCode(conditionExpression); + result = 31 * result + Objects.hashCode(updateExpression); + result = 31 * result + Objects.hashCode(updateExpressionMergeStrategy); result = 31 * result + Objects.hashCode(returnValuesOnConditionCheckFailure); return result; } @@ -175,6 +207,8 @@ public static final class Builder { private Boolean ignoreNulls; private IgnoreNullsMode ignoreNullsMode; private Expression conditionExpression; + private UpdateExpression updateExpression; + private UpdateExpressionMergeStrategy updateExpressionMergeStrategy; private String returnValuesOnConditionCheckFailure; private Builder() { @@ -227,6 +261,37 @@ public Builder item(T item) { return this; } + /** + * Specifies custom update actions using DynamoDB's native update expression syntax. This expression is combined with + * POJO-derived actions and extension-provided actions. + *

+ * Use {@link #updateExpressionMergeStrategy(UpdateExpressionMergeStrategy)} to control how conflicts between these + * sources are resolved ({@link UpdateExpressionMergeStrategy#LEGACY} vs + * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}). + * + * @param updateExpression the update operations to perform + * @return a builder of this type + * @see UpdateExpressionMergeStrategy + */ + public Builder updateExpression(UpdateExpression updateExpression) { + this.updateExpression = updateExpression; + return this; + } + + /** + * Sets how update actions from POJO attributes, extensions, and this request's expression are combined. Defaults to + * {@link UpdateExpressionMergeStrategy#LEGACY}. See {@link UpdateExpressionMergeStrategy} for behavior of each mode. + * + * @param updateExpressionMergeStrategy the merge strategy to use + * @return a builder of this type + * @see UpdateExpressionMergeStrategy + */ + public Builder updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy updateExpressionMergeStrategy) { + this.updateExpressionMergeStrategy = updateExpressionMergeStrategy; + return this; + } + /** * Use ReturnValuesOnConditionCheckFailure to get the item attributes if the ConditionCheck * condition fails. For ReturnValuesOnConditionCheckFailure, the valid values are: NONE and diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateExpressionMergeStrategy.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateExpressionMergeStrategy.java new file mode 100644 index 000000000000..1da438a75547 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateExpressionMergeStrategy.java @@ -0,0 +1,63 @@ +/* + * 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.model; + +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; + +/** + * Controls how update actions from the POJO item, {@link DynamoDbEnhancedClientExtension extensions}, and the request’s + * {@link UpdateExpression} are merged into a single update expression for DynamoDB. + * + *

{@link #LEGACY} (default) — Concatenate all actions. DynamoDB fails the request if any document paths overlap. + * Additionally, when a POJO field is {@code null}, its {@code REMOVE} is omitted if that attribute name also appears in an + * extension or request expression, so the same name is not both removed and set in one call. + * + *

{@link #PRIORITIZE_HIGHER_SOURCE} — Omits lower-priority actions whose resolved path overlaps a + * higher-priority path, using the same overlap rules as DynamoDB. Expression name placeholders ({@code #token}) are resolved + * first. Two paths overlap if they are equal or one continues the other at {@code .} (map segment) or {@code [} (list index). + * Sibling map keys or different list indices do not overlap. + * + *

When paths overlap: the request wins over the extension over the POJO. + * + *

How the merged expression is built: + *

    + *
  1. Include all update actions from the request.
  2. + *
  3. Include an extension action only if its path does not overlap any request path.
  4. + *
  5. Include a POJO-derived action only if its path does not overlap any request path and does not overlap any extension + * action included in step 2.
  6. + *
+ *

Extension actions omitted in step 2 are not part of the merged expression and are not considered when applying step 3. + * + *

Examples: + *

    + *
  • {@code profile.name} and {@code profile.city} — no overlap; both may appear in the merged expression.
  • + *
  • {@code profile} and {@code profile.name} — overlap; the lower-priority action is omitted.
  • + *
  • {@code items[0]} and {@code items[1]} — no overlap; both may appear in the merged expression.
  • + *
  • Two actions on {@code items[0]} — overlap; only the higher-priority source contributes its action.
  • + *
+ * + *

Default: {@link #LEGACY}. + * + * @see UpdateItemEnhancedRequest.Builder#updateExpressionMergeStrategy(UpdateExpressionMergeStrategy) + * @see TransactUpdateItemEnhancedRequest.Builder#updateExpressionMergeStrategy(UpdateExpressionMergeStrategy) + */ +@SdkPublicApi +public enum UpdateExpressionMergeStrategy { + LEGACY, + PRIORITIZE_HIGHER_SOURCE +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java index f7e714c7a690..cef498c04142 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java @@ -24,6 +24,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; import software.amazon.awssdk.services.dynamodb.model.ReturnConsumedCapacity; @@ -49,6 +50,8 @@ public final class UpdateItemEnhancedRequest { private final Boolean ignoreNulls; private final IgnoreNullsMode ignoreNullsMode; private final Expression conditionExpression; + private final UpdateExpression updateExpression; + private final UpdateExpressionMergeStrategy updateExpressionMergeStrategy; private final String returnValues; private final String returnConsumedCapacity; private final String returnItemCollectionMetrics; @@ -59,6 +62,8 @@ private UpdateItemEnhancedRequest(Builder builder) { this.item = builder.item; this.ignoreNulls = builder.ignoreNulls; this.conditionExpression = builder.conditionExpression; + this.updateExpression = builder.updateExpression; + this.updateExpressionMergeStrategy = builder.updateExpressionMergeStrategy; this.ignoreNullsMode = builder.ignoreNullsMode; this.returnValues = builder.returnValues; this.returnConsumedCapacity = builder.returnConsumedCapacity; @@ -85,6 +90,8 @@ public Builder toBuilder() { .ignoreNulls(ignoreNulls) .ignoreNullsMode(ignoreNullsMode) .conditionExpression(conditionExpression) + .updateExpression(updateExpression) + .updateExpressionMergeStrategy(updateExpressionMergeStrategy) .returnValues(returnValues) .returnConsumedCapacity(returnConsumedCapacity) .returnItemCollectionMetrics(returnItemCollectionMetrics) @@ -121,6 +128,23 @@ public Expression conditionExpression() { return conditionExpression; } + /** + * Returns the update expression {@link UpdateExpression} set on this request object, or null if it doesn't exist. + */ + public UpdateExpression updateExpression() { + return updateExpression; + } + + /** + * Returns how POJO, extension, and request update actions are merged. Defaults to + * {@link UpdateExpressionMergeStrategy#LEGACY} when unset on the builder. + */ + public UpdateExpressionMergeStrategy updateExpressionMergeStrategy() { + return updateExpressionMergeStrategy == null + ? UpdateExpressionMergeStrategy.LEGACY + : updateExpressionMergeStrategy; + } + /** * Whether to return the values of the item before this request. * @@ -210,6 +234,8 @@ public boolean equals(Object o) { return Objects.equals(item, that.item) && Objects.equals(ignoreNulls, that.ignoreNulls) && Objects.equals(conditionExpression, that.conditionExpression) + && Objects.equals(updateExpression, that.updateExpression) + && Objects.equals(updateExpressionMergeStrategy, that.updateExpressionMergeStrategy) && Objects.equals(returnValues, that.returnValues) && Objects.equals(returnConsumedCapacity, that.returnConsumedCapacity) && Objects.equals(returnItemCollectionMetrics, that.returnItemCollectionMetrics) @@ -221,6 +247,8 @@ public int hashCode() { int result = item != null ? item.hashCode() : 0; result = 31 * result + (ignoreNulls != null ? ignoreNulls.hashCode() : 0); result = 31 * result + (conditionExpression != null ? conditionExpression.hashCode() : 0); + result = 31 * result + (updateExpression != null ? updateExpression.hashCode() : 0); + result = 31 * result + (updateExpressionMergeStrategy != null ? updateExpressionMergeStrategy.hashCode() : 0); result = 31 * result + (returnValues != null ? returnValues.hashCode() : 0); result = 31 * result + (returnConsumedCapacity != null ? returnConsumedCapacity.hashCode() : 0); result = 31 * result + (returnItemCollectionMetrics != null ? returnItemCollectionMetrics.hashCode() : 0); @@ -239,6 +267,8 @@ public static final class Builder { private Boolean ignoreNulls; private IgnoreNullsMode ignoreNullsMode; private Expression conditionExpression; + private UpdateExpression updateExpression; + private UpdateExpressionMergeStrategy updateExpressionMergeStrategy; private String returnValues; private String returnConsumedCapacity; private String returnItemCollectionMetrics; @@ -313,6 +343,39 @@ public Builder item(T item) { return this; } + /** + * Specifies custom update actions using DynamoDB's native update expression syntax. This expression is combined with + * POJO-derived actions and extension-provided actions. + *

+ * Use {@link #updateExpressionMergeStrategy(UpdateExpressionMergeStrategy)} to control how conflicts between these + * sources are resolved ({@link UpdateExpressionMergeStrategy#LEGACY concatenation} vs + * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE document path overlap resolution}). + * + * @param updateExpression the update operations to perform + * @return a builder of this type + * @see UpdateExpressionMergeStrategy + */ + public Builder updateExpression(UpdateExpression updateExpression) { + this.updateExpression = updateExpression; + return this; + } + + /** + * Sets how update actions from POJO attributes, extensions, and this request's expression are combined. Defaults to + * {@link UpdateExpressionMergeStrategy#LEGACY} (concatenate all actions; DynamoDB may reject overlapping paths). + * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE} drops lower-priority actions only when their path + * overlaps a higher-priority path (see {@link UpdateExpressionMergeStrategy}). + * + * @param updateExpressionMergeStrategy the merge strategy to use + * @return a builder of this type + * @see UpdateExpressionMergeStrategy + */ + public Builder updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy updateExpressionMergeStrategy) { + this.updateExpressionMergeStrategy = updateExpressionMergeStrategy; + return this; + } + /** * Whether to return the capacity consumed by this operation. * diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateExpressionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateExpressionTest.java index e2271f424d3b..1c2ae1faa9c3 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateExpressionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/UpdateExpressionTest.java @@ -2,8 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior.WRITE_IF_NOT_EXISTS; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.LEGACY; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -17,19 +23,46 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification; -import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.RecordForUpdateExpressions; import software.amazon.awssdk.enhanced.dynamodb.internal.operations.OperationName; -import software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation; -import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; +import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactGetItemsEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactUpdateItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy; +import software.amazon.awssdk.enhanced.dynamodb.model.WriteBatch; import software.amazon.awssdk.enhanced.dynamodb.update.AddAction; import software.amazon.awssdk.enhanced.dynamodb.update.DeleteAction; +import software.amazon.awssdk.enhanced.dynamodb.update.SetAction; import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.DynamoDbException; import software.amazon.awssdk.utils.CollectionUtils; +/** + * Functional tests for {@code updateItem} with extension-provided and request-level {@link UpdateExpression}s. + *

+ * {@link UpdateExpressionMergeStrategy} (integration-level cases exercised here) + *

    + *
  • LEGACY, same scalar, POJO + request → DynamoDB overlap error
  • + *
  • PRIORITIZE_HIGHER_SOURCE, same scalar, POJO + request → request value stored
  • + *
  • LEGACY, document root vs nested path, POJO + request → overlap error
  • + *
  • PRIORITIZE_HIGHER_SOURCE, document root vs nested → nested request path stored
  • + *
  • PRIORITIZE_HIGHER_SOURCE, list, POJO + extension + request → non-overlapping indices compose (e.g. ext[0] + req[1])
  • + *
  • PRIORITIZE_HIGHER_SOURCE, list of maps, three sources → request and extension sibling paths both apply when + * non-overlapping
  • + *
  • LEGACY, extension + request same scalar → overlap error
  • + *
  • PRIORITIZE_HIGHER_SOURCE, extension + request same scalar → request wins
  • + *
  • PRIORITIZE_HIGHER_SOURCE, disjoint top-level names → both mutations apply
  • + *
  • Default merge strategy, extension + request same scalar → same overlap error as LEGACY
  • + *
+ */ public class UpdateExpressionTest extends LocalDynamoDbSyncTestBase { + private static final List REQUEST_ATTRIBUTES = new ArrayList<>(Arrays.asList("attr1", "attr2")); + private static final Set SET_ATTRIBUTE_INIT_VALUE = Stream.of("YELLOW", "BLUE", "RED", "GREEN") .collect(Collectors.toSet()); private static final Set SET_ATTRIBUTE_DELETE = Stream.of("YELLOW", "RED").collect(Collectors.toSet()); @@ -39,7 +72,9 @@ public class UpdateExpressionTest extends LocalDynamoDbSyncTestBase { private static final String NUMBER_ATTRIBUTE_VALUE_REF = ":increment_value_ref"; private static final String SET_ATTRIBUTE_REF = "extensionSetAttribute"; - private static final TableSchema TABLE_SCHEMA = TableSchema.fromClass(RecordForUpdateExpressions.class); + private static final String TABLE_NAME = "table-name"; + private static final TableSchema TABLE_SCHEMA = + TableSchema.fromClass(RecordForUpdateExpressions.class); private DynamoDbTable mappedTable; private void initClientWithExtensions(DynamoDbEnhancedClientExtension... extensions) { @@ -48,19 +83,92 @@ private void initClientWithExtensions(DynamoDbEnhancedClientExtension... extensi .extensions(extensions) .build(); - mappedTable = enhancedClient.table(getConcreteTableName("table-name"), TABLE_SCHEMA); + mappedTable = enhancedClient.table(getConcreteTableName(TABLE_NAME), TABLE_SCHEMA); mappedTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + getDynamoDbClient().waiter().waitUntilTableExists(r -> r.tableName(getConcreteTableName(TABLE_NAME))); } @After public void deleteTable() { - getDynamoDbClient().deleteTable(r -> r.tableName(getConcreteTableName("table-name"))); + if (mappedTable != null) { + getDynamoDbClient().deleteTable(r -> r.tableName(getConcreteTableName(TABLE_NAME))); + } } + // --- Atomic counter (SET with if_not_exists + add) --- + @Test - public void attribute_notInPojo_notFilteredInExtension_ignoresNulls_updatesNormally() { + public void updateItem_givenCounterAbsent_whenIfNotExistsIncrementExpression_thenStoresIncrement() { + initClientWithExtensions(); + + RecordForUpdateExpressions initialRecord = createKeyOnlyRecord(); + initialRecord.setId("atomicCounter1"); + mappedTable.putItem(initialRecord); + + long incrementBy = 30L; + + UpdateExpression updateExpression = UpdateExpression + .builder() + .addAction( + SetAction.builder() + .path("incrementedAttribute") + .value("if_not_exists(incrementedAttribute, :zero) + :increment") + .putExpressionValue(":zero", AttributeValue.builder().n("0").build()) + .putExpressionValue( + ":increment", + AttributeValue.builder().n(Long.toString(incrementBy)).build()) + .build()) + .build(); + + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + keyRecord.setId("atomicCounter1"); + + mappedTable.updateItem(r -> r.item(keyRecord).updateExpression(updateExpression)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + assertThat(persistedRecord.getIncrementedAttribute()).isEqualTo(30L); + } + + @Test + public void updateItem_givenCounterPresent_whenIfNotExistsIncrementExpression_thenAddsToExistingValue() { + initClientWithExtensions(); + + RecordForUpdateExpressions initialRecord = createKeyOnlyRecord(); + initialRecord.setId("atomicCounter2"); + initialRecord.setIncrementedAttribute(10L); + mappedTable.putItem(initialRecord); + + long incrementBy = 30L; + + UpdateExpression updateExpression = UpdateExpression + .builder() + .addAction( + SetAction.builder() + .path("incrementedAttribute") + .value("if_not_exists(incrementedAttribute, :zero) + :increment") + .putExpressionValue(":zero", AttributeValue.builder().n("0").build()) + .putExpressionValue( + ":increment", + AttributeValue.builder().n(Long.toString(incrementBy)).build()) + .build()) + .build(); + + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + keyRecord.setId("atomicCounter2"); + + mappedTable.updateItem(r -> r.item(keyRecord) + .updateExpression(updateExpression)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + assertThat(persistedRecord.getIncrementedAttribute()).isEqualTo(40L); + } + + // --- Extension vs POJO: preserving / filtering extensions --- + + @Test + public void updateItem_givenPreservingExtension_attributeAbsentFromPojo_whenIgnoreNullsTrue_thenExtensionFieldsUnchanged() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.updateItem(r -> r.item(record).ignoreNulls(true)); @@ -70,20 +178,15 @@ public void attribute_notInPojo_notFilteredInExtension_ignoresNulls_updatesNorma } /** - * This test case represents the most likely extension UpdateExpression use case; - * an attribute is set in the extensions and isn't present in the request POJO item, and there is no change in - * the request to set ignoreNull to true. + * Extension-only attributes must survive default update behavior. *

- * By default, ignorNull is false, so attributes that aren't set on the request are deleted from the DDB table through - * the updateItemOperation generating REMOVE actions for those attributes. This is prevented by - * {@link UpdateItemOperation} using {@link UpdateExpressionConverter#findAttributeNames(UpdateExpression)} - * to not create REMOVE actions attributes it finds referenced in an extension UpdateExpression. - * Therefore, this use case updates normally. + * With {@code ignoreNulls=false}, POJO-null fields would normally produce REMOVE actions. This verifies that attributes + * referenced by extension expressions are excluded from generated REMOVE actions to avoid self-conflicts. */ @Test - public void attribute_notInPojo_notFilteredInExtension_defaultSetsNull_updatesNormally() { + public void updateItem_givenPreservingExtension_attributeAbsentFromPojo_whenIgnoreNullsFalse_thenRemoveSuppressedForExtensionPath() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.updateItem(r -> r.item(record)); @@ -93,10 +196,9 @@ public void attribute_notInPojo_notFilteredInExtension_defaultSetsNull_updatesNo } @Test - public void attribute_notInPojo_filteredInExtension_ignoresNulls_updatesNormally() { + public void updateItem_givenFilteringExtension_attributeAbsentFromPojo_whenIgnoreNullsTrue_thenSetMutationApplied() { initClientWithExtensions(new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); - record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setStringAttribute("init"); @@ -106,10 +208,9 @@ public void attribute_notInPojo_filteredInExtension_ignoresNulls_updatesNormally } @Test - public void attribute_notInPojo_filteredInExtension_defaultSetsNull_updatesNormally() { + public void updateItem_givenFilteringExtension_attributeAbsentFromPojo_whenIgnoreNullsFalse_thenSetMutationApplied() { initClientWithExtensions(new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); - record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setStringAttribute("init"); @@ -119,37 +220,35 @@ public void attribute_notInPojo_filteredInExtension_defaultSetsNull_updatesNorma } /** - * The extension adds an UpdateExpression with the number attribute, and the request - * results in an UpdateExpression with the number attribute. This causes DDB to reject the request. + * The extension adds an UpdateExpression with the number attribute, and the request results in an UpdateExpression with the + * number attribute. This causes DDB to reject the request. */ @Test - public void attribute_inPojo_notFilteredInExtension_ignoresNulls_ddbError() { + public void updateItem_givenPreservingExtension_attributeInPojo_whenIgnoreNullsTrue_thenDynamoDbRejectsOverlappingPaths() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); record.setExtensionNumberAttribute(100L); verifyDDBError(record, true); } @Test - public void attribute_inPojo_notFilteredInExtension_defaultSetsNull_ddbError() { + public void updateItem_givenPreservingExtension_attributeInPojo_whenIgnoreNullsFalse_thenDynamoDbRejectsOverlappingPaths() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); record.setExtensionNumberAttribute(100L); verifyDDBError(record, false); } /** - * When the extension filters the transact item representing the request POJO attributes and removes the attribute - * from the POJO if it's there, only the extension UpdateExpression will reference the attribute and no DDB - * conflict results. + * When the extension filters the transact item representing the request POJO attributes and removes the attribute from the + * POJO if it's there, only the extension UpdateExpression will reference the attribute and no DDB conflict results. */ @Test - public void attribute_inPojo_filteredInExtension_ignoresNulls_updatesNormally() { + public void updateItem_givenFilteringExtension_attributeInPojo_whenIgnoreNullsTrue_thenNoConflict() { initClientWithExtensions(new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); - record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setStringAttribute("init"); @@ -160,10 +259,9 @@ public void attribute_inPojo_filteredInExtension_ignoresNulls_updatesNormally() } @Test - public void attribute_inPojo_filteredInExtension_defaultSetsNull_updatesNormally() { + public void updateItem_givenFilteringExtension_attributeInPojo_whenIgnoreNullsFalse_thenNoConflict() { initClientWithExtensions(new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); - record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setStringAttribute("init"); @@ -173,15 +271,16 @@ public void attribute_inPojo_filteredInExtension_defaultSetsNull_updatesNormally verifySetAttribute(record); } + // --- Chained extensions (duplicate vs distinct paths) --- + @Test - public void chainedExtensions_noDuplicates_ignoresNulls_updatesNormally() { + public void updateItem_givenTwoExtensionsDistinctPaths_whenIgnoreNullsTrue_thenBothMutationsApply() { initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions putRecord = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions putRecord = createFullRecord(); putRecord.setExtensionNumberAttribute(11L); - putRecord.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); mappedTable.putItem(putRecord); - RecordForUpdateExpressions updateRecord = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions updateRecord = createFullRecord(); updateRecord.setStringAttribute("updated"); mappedTable.updateItem(r -> r.item(updateRecord).ignoreNulls(true)); @@ -192,45 +291,628 @@ public void chainedExtensions_noDuplicates_ignoresNulls_updatesNormally() { } @Test - public void chainedExtensions_duplicateAttributes_sameValue_sameValueRef_ddbError() { + public void updateItem_givenDuplicatePreservingExtensions_sameValueAndPlaceholder_whenUpdate_thenDynamoDbRejectsOverlap() { initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemPreservingUpdateExtension()); - verifyDDBError(createRecordWithoutExtensionAttributes(), false); + verifyDDBError(createFullRecord(), false); } @Test - public void chainedExtensions_duplicateAttributes_sameValue_differentValueRef_ddbError() { - initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemPreservingUpdateExtension(NUMBER_ATTRIBUTE_VALUE, ":ref")); - verifyDDBError(createRecordWithoutExtensionAttributes(), false); + public void updateItem_givenDuplicatePreservingExtensions_sameValueDifferentPlaceholder_whenUpdate_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(new ItemPreservingUpdateExtension(), + new ItemPreservingUpdateExtension(NUMBER_ATTRIBUTE_VALUE, ":ref")); + verifyDDBError(createFullRecord(), false); } @Test - public void chainedExtensions_duplicateAttributes_differentValue_differentValueRef_ddbError() { + public void updateItem_givenDuplicatePreservingExtensions_differentValues_whenUpdate_thenDynamoDbRejectsOverlap() { initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemPreservingUpdateExtension(13L, ":ref")); - verifyDDBError(createRecordWithoutExtensionAttributes(), false); + verifyDDBError(createFullRecord(), false); } @Test - public void chainedExtensions_duplicateAttributes_differentValue_sameValueRef_operationMergeError() { - initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemPreservingUpdateExtension(10L, NUMBER_ATTRIBUTE_VALUE_REF)); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + public void updateItem_givenDuplicatePreservingExtensions_conflictingValuesSamePlaceholder_whenUpdate_thenIllegalArgumentException() { + initClientWithExtensions(new ItemPreservingUpdateExtension(), + new ItemPreservingUpdateExtension(10L, NUMBER_ATTRIBUTE_VALUE_REF)); + RecordForUpdateExpressions record = createFullRecord(); - assertThatThrownBy(() ->mappedTable.updateItem(r -> r.item(record))) + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(record))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Attempt to coalesce two expressions with conflicting expression values") .hasMessageContaining(NUMBER_ATTRIBUTE_VALUE_REF); } @Test - public void chainedExtensions_duplicateAttributes_invalidValueRef_operationMergeError() { + public void updateItem_givenDuplicatePreservingExtensions_invalidPlaceholder_whenUpdate_thenDynamoDbException() { initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemPreservingUpdateExtension(10L, "illegal")); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); - assertThatThrownBy(() ->mappedTable.updateItem(r -> r.item(record))) + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(record))) .isInstanceOf(DynamoDbException.class) .hasMessageContaining("ExpressionAttributeValues contains invalid key") .hasMessageContaining("illegal"); } + /** + * Tests that explicit UpdateExpression provided on the request prevents REMOVE actions for the referenced attributes. + * Normally, null item attributes generate REMOVE actions when ignoreNulls=false. When an UpdateExpression is provided on the + * request, REMOVE actions are suppressed for attributes referenced in that UpdateExpression to avoid conflicts. + */ + // --- Request-level UpdateExpression on list attribute (REMOVE suppression) --- + @Test + public void updateItem_givenRequestExpressionSetsListIndex_whenIgnoreNullsFalse_thenListUpdatedWithoutRemoveConflict() { + initClientWithExtensions(); + RecordForUpdateExpressions initialRecord = createFullRecord(); + putInitialItemAndVerify(initialRecord); + + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(updateExpressionSetRequestListElement(1, "attr3"))); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + List requestAttributeList = persistedRecord.getRequestAttributeList(); + assertThat(requestAttributeList).hasSize(2).containsExactly("attr1", "attr3"); + } + + /** + * Tests that explicit UpdateExpression provided on the request works with ignoreNulls=true. When ignoreNulls=true, null item + * attributes are ignored and no REMOVE actions are generated. When an UpdateExpression is provided on the request, it + * operates independently of the ignoreNulls setting and updates the specified attributes. + */ + @Test + public void updateItem_givenRequestExpressionSetsListIndex_whenIgnoreNullsTrue_thenListUpdated() { + initClientWithExtensions(); + RecordForUpdateExpressions initialRecord = createFullRecord(); + putInitialItemAndVerify(initialRecord); + + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + mappedTable.updateItem( + r -> r.item(keyRecord) + .ignoreNulls(true) + .updateExpression(updateExpressionSetRequestListElement(1, "attr3"))); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + List requestAttributeList = persistedRecord.getRequestAttributeList(); + assertThat(requestAttributeList).hasSize(2).containsExactly("attr1", "attr3"); + } + + /** + * A request expression that targets the same path as the POJO update is rejected by DynamoDB as overlapping paths. + */ + @Test + public void updateItem_givenRequestExpressionOverlapsPojoPath_whenUpdate_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(); + RecordForUpdateExpressions initialRecord = createFullRecord(); + putInitialItemAndVerify(initialRecord); + + RecordForUpdateExpressions updateRecord = createKeyOnlyRecord(); + updateRecord.setRequestAttributeList(Collections.singletonList("attr1")); + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(updateRecord) + .updateExpression(updateExpressionSetRequestListElement(1, "attr3")))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths overlap"); + } + + // --- Merge strategy: scalar (POJO vs request) --- + + @Test + public void updateItem_givenLegacyMergeStrategy_whenPojoAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setExtensionNumberAttribute(100L); + UpdateExpression reqExpression = updateExpressionSetLongAttribute("extensionNumberAttribute", 200L); + + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(record) + .updateExpression(reqExpression) + .updateExpressionMergeStrategy(LEGACY))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths"); + } + + @Test + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoAndRequestSetSameScalar_thenRequestValuePersists() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setExtensionNumberAttribute(100L); + mappedTable.updateItem( + r -> r.item(record) + .updateExpression(updateExpressionSetLongAttribute("extensionNumberAttribute", 200L)) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(200L); + } + + // --- Merge strategy: list (POJO + extension + request) --- + + @Test + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoExtensionAndRequestTouchSameList_thenNonOverlappingIndicesCompose() { + initClientWithExtensions(new ListFirstElementUpdateExtension()); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setRequestAttributeList(Arrays.asList("pojo1", "pojo2")); + mappedTable.updateItem( + r -> r.item(record) + .updateExpression(updateExpressionSetRequestListElement(1, "request1")) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getRequestAttributeList()).containsExactly("extension0", "request1"); + } + + // --- Merge strategy: document path (root vs nested) --- + + @Test + public void updateItem_givenLegacyMergeStrategy_whenPojoSetsDocumentRootAndRequestSetsNestedField_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setObjectAttribute(nestedObject("pojoName", "pojoCity")); + + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(record) + .updateExpression( + UpdateExpression.builder() + .addAction( + SetAction.builder() + .path("#objectAttribute.#name") + .value(":str_requestName") + .putExpressionName("#objectAttribute", "objectAttribute") + .putExpressionName("#name", "name") + .putExpressionValue( + ":str_requestName", + AttributeValue.builder().s("requestName").build()) + .build()) + .build()) + .updateExpressionMergeStrategy(LEGACY))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths"); + } + + @Test + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoSetsDocumentRootAndRequestSetsNestedField_thenNestedPathPersists() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + RecordForUpdateExpressions afterPut = mappedTable.getItem(record); + assertThat(afterPut.getObjectAttribute().getCity()).isEqualTo("originCity"); + + record.setObjectAttribute(nestedObject("pojoName", "pojoCity")); + mappedTable.updateItem( + r -> r.item(record) + .updateExpression( + UpdateExpression.builder() + .addAction( + SetAction.builder() + .path("#objectAttribute.#name") + .value(":str_requestName") + .putExpressionName("#objectAttribute", "objectAttribute") + .putExpressionName("#name", "name") + .putExpressionValue( + ":str_requestName", + AttributeValue.builder().s("requestName").build()) + .build()) + .build()) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getObjectAttribute().getName()).isEqualTo("requestName"); + assertThat(persistedRecord.getObjectAttribute().getCity()).isEqualTo("originCity"); + } + + // --- Merge strategy: list of maps (three sources) --- + + @Test + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoExtensionAndRequestTouchObjectList_thenRequestNestedUpdatePersists() { + initClientWithExtensions(new ObjectListFirstElementNameUpdateExtension()); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setObjectListAttribute(Arrays.asList( + nestedObject("pojo0", "pojoCity0"), + nestedObject("pojo1", "pojoCity1"))); + mappedTable.updateItem( + r -> r.item(record) + .updateExpression( + UpdateExpression.builder() + .addAction( + SetAction.builder() + .path("#objectListAttribute[1].#name") + .value(":str_requestObject1") + .putExpressionName("#objectListAttribute", "objectListAttribute") + .putExpressionName("#name", "name") + .putExpressionValue( + ":str_requestObject1", + AttributeValue.builder().s("requestObject1").build()) + .build()) + .build()) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getObjectListAttribute().get(0).getName()).isEqualTo("extensionObject0"); + assertThat(persistedRecord.getObjectListAttribute().get(0).getCity()).isEqualTo("originCity0"); + assertThat(persistedRecord.getObjectListAttribute().get(1).getName()).isEqualTo("requestObject1"); + assertThat(persistedRecord.getObjectListAttribute().get(1).getCity()).isEqualTo("originCity1"); + } + + // --- Merge strategy: extension vs request (same scalar) --- + + @Test + public void updateItem_givenLegacyMergeStrategy_whenExtensionAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(new ItemPreservingUpdateExtension()); + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + + UpdateExpression reqExpression = + updateExpressionSetLongAttribute("extensionNumberAttribute", 99L); + + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(reqExpression) + .updateExpressionMergeStrategy(LEGACY))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths"); + } + + @Test + public void updateItem_givenPrioritizeHigherSourceMerge_whenExtensionAndRequestSetSameScalar_thenRequestValuePersists() { + initClientWithExtensions(new ItemPreservingUpdateExtension()); + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + mappedTable.putItem(keyRecord); + + mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(updateExpressionSetLongAttribute("extensionNumberAttribute", 99L)) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(99L); + } + + @Test + public void updateItem_givenPrioritizeHigherSourceMerge_whenDisjointTopLevelNames_thenBothMutationsApply() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + RecordForUpdateExpressions updateRecord = createKeyOnlyRecord(); + updateRecord.setStringAttribute("updated"); + mappedTable.updateItem( + r -> r.item(updateRecord) + .ignoreNulls(true) + .updateExpression(updateExpressionSetRequestListElement(0, "reqVal")) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(updateRecord); + // stringAttribute uses WRITE_IF_NOT_EXISTS in the schema, so an existing value is preserved on update. + assertThat(persistedRecord.getStringAttribute()).isEqualTo("init"); + assertThat(persistedRecord.getRequestAttributeList().get(0)).isEqualTo("reqVal"); + } + + @Test + public void updateItem_givenDefaultMergeStrategy_whenExtensionAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(new ItemPreservingUpdateExtension()); + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + + UpdateExpression reqExpression = + UpdateExpression.builder() + .addAction( + SetAction.builder() + .path("extensionNumberAttribute") + .value(":conflictValue") + .putExpressionValue( + ":conflictValue", + AttributeValue.builder().n("99").build()) + .build()) + .build(); + + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(keyRecord).updateExpression(reqExpression))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths") + .hasMessageContaining(NUMBER_ATTRIBUTE_REF); + } + + // --- Backward compatibility (no request-level UpdateExpression) --- + + /** + * POJO-only updates are unchanged. Request-level UpdateExpression is opt-in; without it, behavior matches earlier releases. + */ + @Test + public void updateItem_givenNoRequestExpression_whenPojoOnlyUpdate_thenScalarPersists() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createSimpleRecord(); + + // Backward-compatible baseline: POJO update flow without request-level expression. + mappedTable.putItem(record); + record.setExtensionNumberAttribute(100L); + mappedTable.updateItem(r -> r.item(record)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(100L); + } + + /** + * Backward compatibility: extension-only updates are unchanged when no request-level UpdateExpression is supplied. + */ + @Test + public void updateItem_givenNoRequestExpression_whenExtensionOnlyUpdate_thenExtensionValuePersists() { + initClientWithExtensions(new ItemPreservingUpdateExtension()); + RecordForUpdateExpressions record = createSimpleRecord(); + + // Backward-compatible baseline: extension-only mutation without request-level expression. + mappedTable.putItem(record); + mappedTable.updateItem(r -> r.item(record)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(5L); + } + + // --- Other APIs after request-level UpdateExpression (scan, batch, transact) --- + + /** + * Verifies {@code scan} returns items updated earlier with a request-level UpdateExpression. + */ + @Test + public void scan_givenRequestExpressionUpdatedList_whenScan_thenReadItemReflectsUpdate() { + initClientWithExtensions(); + RecordForUpdateExpressions record1 = createFullRecord(); + record1.setId("scan1"); + RecordForUpdateExpressions record2 = createFullRecord(); + record2.setId("scan2"); + + mappedTable.putItem(record1); + mappedTable.putItem(record2); + + // Update one record with expression using key-only record to avoid path conflicts + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + keyRecord.setId("scan1"); + mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(updateExpressionSetRequestListElement(0, "updated"))); + + // Scan and verify both records + List scannedItems = + mappedTable.scan().items().stream().collect(Collectors.toList()); + assertThat(scannedItems).hasSize(2); + + RecordForUpdateExpressions updatedRecord = scannedItems.stream() + .filter(r -> "scan1".equals(r.getId())) + .findFirst() + .get(); + assertThat(updatedRecord.getRequestAttributeList().get(0)).isEqualTo("updated"); + } + + /** + * Verifies {@code deleteItem} succeeds after an update that used a request-level UpdateExpression. + */ + @Test + public void deleteItem_givenRequestExpressionUpdatedList_whenDeleteKey_thenItemAbsent() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + // Update with expression using key-only record to avoid path conflicts + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(updateExpressionSetRequestListElement(0, "beforeDelete"))); + + // Verify update worked + RecordForUpdateExpressions updatedRecord = mappedTable.getItem(record); + assertThat(updatedRecord.getRequestAttributeList().get(0)).isEqualTo("beforeDelete"); + + // Delete the item + mappedTable.deleteItem(record); + + // Verify deletion + RecordForUpdateExpressions deletedRecord = mappedTable.getItem(record); + assertThat(deletedRecord).isNull(); + } + + /** + * Verifies {@code batchGetItem} returns items updated with request-level UpdateExpressions. + */ + @Test + public void batchGetItem_givenRequestExpressionUpdatedTwoItems_whenBatchGet_thenBothReflectUpdates() { + initClientWithExtensions(); + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + RecordForUpdateExpressions record1 = createFullRecord(); + record1.setId("batch1"); + RecordForUpdateExpressions record2 = createFullRecord(); + record2.setId("batch2"); + + mappedTable.putItem(record1); + mappedTable.putItem(record2); + + // Update both with expressions using key-only records to avoid path conflicts + RecordForUpdateExpressions keyRecord1 = createKeyOnlyRecord(); + keyRecord1.setId("batch1"); + mappedTable.updateItem( + r -> r.item(keyRecord1) + .updateExpression(updateExpressionSetRequestListElement(0, "batch1Updated"))); + RecordForUpdateExpressions keyRecord2 = createKeyOnlyRecord(); + keyRecord2.setId("batch2"); + mappedTable.updateItem( + r -> r.item(keyRecord2) + .updateExpression(updateExpressionSetRequestListElement(0, "batch2Updated"))); + + // Batch get both items + List batchResults = + enhancedClient + .batchGetItem( + r -> r.readBatches( + ReadBatch.builder(RecordForUpdateExpressions.class) + .mappedTableResource(mappedTable) + .addGetItem(record1) + .addGetItem(record2) + .build())) + .resultsForTable(mappedTable) + .stream() + .collect(Collectors.toList()); + + assertThat(batchResults).hasSize(2); + assertThat(batchResults.stream().map(r -> r.getRequestAttributeList().get(0))) + .containsExactlyInAnyOrder("batch1Updated", "batch2Updated"); + } + + /** + * Verifies {@code batchWriteItem} with put and delete after a request-level UpdateExpression update. + */ + @Test + public void batchWriteItem_givenUpdatedItemAndPutDelete_whenBatchWrite_thenPutVisibleAndDeleteSucceeds() { + initClientWithExtensions(); + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + RecordForUpdateExpressions record1 = createFullRecord(); + record1.setId("batchWrite1"); + RecordForUpdateExpressions record2 = createFullRecord(); + record2.setId("batchWrite2"); + + // First update with expressions using key-only record to avoid path conflicts + mappedTable.putItem(record1); + RecordForUpdateExpressions keyRecord1 = createKeyOnlyRecord(); + keyRecord1.setId("batchWrite1"); + mappedTable.updateItem( + r -> r.item(keyRecord1) + .updateExpression(updateExpressionSetRequestListElement(0, "preWrite"))); + + // Batch write new record and delete updated record + enhancedClient.batchWriteItem(r -> r.writeBatches( + WriteBatch.builder(RecordForUpdateExpressions.class) + .mappedTableResource(mappedTable) + .addPutItem(record2) + .addDeleteItem(record1) + .build())); + + // Verify results + assertThat(mappedTable.getItem(record1)).isNull(); + RecordForUpdateExpressions newRecord = mappedTable.getItem(record2); + assertThat(newRecord).isNotNull(); + assertThat(newRecord.getRequestAttributeList()).containsExactly("attr1", "attr2"); + } + + /** + * Verifies {@code transactGetItems} returns an item updated with a request-level UpdateExpression. + */ + @Test + public void transactGetItems_givenRequestExpressionUpdatedItem_whenTransactGet_thenUpdatedFieldReturned() { + initClientWithExtensions(); + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + RecordForUpdateExpressions record1 = createFullRecord(); + record1.setId("transact1"); + RecordForUpdateExpressions record2 = createFullRecord(); + record2.setId("transact2"); + + mappedTable.putItem(record1); + mappedTable.putItem(record2); + + // Update with expressions using key-only record to avoid path conflicts + RecordForUpdateExpressions keyRecord1 = createKeyOnlyRecord(); + keyRecord1.setId("transact1"); + mappedTable.updateItem( + r -> r.item(keyRecord1) + .updateExpression(updateExpressionSetRequestListElement(0, "transactUpdated"))); + + // Transactional get + List transactResults = + enhancedClient.transactGetItems( + TransactGetItemsEnhancedRequest.builder() + .addGetItem(mappedTable, record1) + .addGetItem(mappedTable, record2) + .build()) + .stream() + .map(doc -> doc.getItem(mappedTable)) + .collect(Collectors.toList()); + + assertThat(transactResults).hasSize(2); + RecordForUpdateExpressions updatedRecord = transactResults.stream() + .filter(r -> "transact1".equals(r.getId())) + .findFirst() + .get(); + assertThat(updatedRecord.getRequestAttributeList().get(0)).isEqualTo("transactUpdated"); + } + + /** + * Verifies {@code transactWriteItems} delete + put in one transaction. + */ + @Test + public void transactWriteItems_givenDeleteAndPutInTransaction_whenExecute_thenOldGoneNewPresent() { + initClientWithExtensions(); + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + RecordForUpdateExpressions record1 = createFullRecord(); + record1.setId("transactWrite1"); + RecordForUpdateExpressions record2 = createFullRecord(); + record2.setId("transactWrite2"); + + mappedTable.putItem(record1); + + // Transactional write operations - delete existing item and put new item + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addDeleteItem(mappedTable, record1) + .addPutItem(mappedTable, record2) + .build()); + + // Verify both operations succeeded + RecordForUpdateExpressions deletedRecord = mappedTable.getItem(record1); + assertThat(deletedRecord).isNull(); + + RecordForUpdateExpressions persistedRecord2 = mappedTable.getItem(record2); + assertThat(persistedRecord2).isNotNull(); + assertThat(persistedRecord2.getRequestAttributeList()).containsExactly("attr1", "attr2"); + } + + // --- Transact update with request-level UpdateExpression --- + + @Test + public void transactWriteItems_givenTransactUpdateWithRequestExpression_whenExecute_thenListElementUpdated() { + initClientWithExtensions(); + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + + RecordForUpdateExpressions record = createFullRecord(); + record.setId("transactUpdateReqExpr"); + mappedTable.putItem(record); + + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + keyRecord.setId("transactUpdateReqExpr"); + + enhancedClient.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addUpdateItem( + mappedTable, + TransactUpdateItemEnhancedRequest + .builder(RecordForUpdateExpressions.class) + .item(keyRecord) + .updateExpression(updateExpressionSetRequestListElement(1, "txn")) + .build()) + .build()); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + assertThat(persistedRecord.getRequestAttributeList()).containsExactly("attr1", "txn"); + } + private void verifyDDBError(RecordForUpdateExpressions record, boolean ignoreNulls) { assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(record).ignoreNulls(ignoreNulls))) .isInstanceOf(DynamoDbException.class) @@ -246,13 +928,89 @@ private void verifySetAttribute(RecordForUpdateExpressions record) { assertThat(persistedRecord.getExtensionSetAttribute()).isEqualTo(expectedAttribute); } - private RecordForUpdateExpressions createRecordWithoutExtensionAttributes() { + /** + * Creates record with only the partition key (id) + */ + private RecordForUpdateExpressions createKeyOnlyRecord() { + RecordForUpdateExpressions record = new RecordForUpdateExpressions(); + record.setId("1"); + return record; + } + + /** + * Creates record with POJO attributes (id + stringAttribute) + */ + private RecordForUpdateExpressions createSimpleRecord() { RecordForUpdateExpressions record = new RecordForUpdateExpressions(); record.setId("1"); record.setStringAttribute("init"); return record; } + /** + * Creates record with POJO + extension + request attributes (requestAttributeList for request UpdateExpressions, + * extensionSetAttribute for extension UpdateExpressions) + */ + private RecordForUpdateExpressions createFullRecord() { + RecordForUpdateExpressions record = createSimpleRecord(); + record.setRequestAttributeList(new ArrayList<>(REQUEST_ATTRIBUTES)); + record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + record.setObjectAttribute(nestedObject("originName", "originCity")); + record.setObjectListAttribute(Arrays.asList( + nestedObject("originObject0", "originCity0"), + nestedObject("originObject1", "originCity1"))); + return record; + } + + private NestedRecordForUpdateExpressions nestedObject(String name, String city) { + NestedRecordForUpdateExpressions nestedObject = new NestedRecordForUpdateExpressions(); + nestedObject.setName(name); + nestedObject.setCity(city); + return nestedObject; + } + + private void putInitialItemAndVerify(RecordForUpdateExpressions record) { + mappedTable.putItem(r -> r.item(record)); + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + List requestAttributeList = persistedRecord.getRequestAttributeList(); + assertThat(requestAttributeList).hasSize(2).isEqualTo(REQUEST_ATTRIBUTES); + } + + /** + * SET {@code requestAttributeList[index]} to a string; uses an expression-attribute name for the list top-level attribute. + */ + private static UpdateExpression updateExpressionSetRequestListElement(int index, String elementValue) { + String valueRef = ":val_" + elementValue.replaceAll("[^a-zA-Z0-9]", "_"); + String listAttr = "requestAttributeList"; + String listToken = keyRef(listAttr); + return UpdateExpression.builder() + .addAction(SetAction.builder() + .path(listToken + "[" + index + "]") + .value(valueRef) + .putExpressionValue( + valueRef, + AttributeValue.builder().s(elementValue).build()) + .putExpressionName(listToken, listAttr) + .build()) + .build(); + } + + /** + * SET a numeric attribute to {@code numericValue} (placeholder {@code :value_}). + */ + private static UpdateExpression updateExpressionSetLongAttribute(String attributeName, long numericValue) { + String valueRef = ":value_" + numericValue; + return UpdateExpression.builder() + .addAction(SetAction.builder() + .path(attributeName) + .value(valueRef) + .putExpressionValue( + valueRef, + AttributeValue.builder().n(Long.toString(numericValue)).build()) + .build()) + .build(); + } + private static final class ItemPreservingUpdateExtension implements DynamoDbEnhancedClientExtension { private long incrementValue; private String valueRef; @@ -290,7 +1048,7 @@ private static final class ItemFilteringUpdateExtension implements DynamoDbEnhan public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { Map transformedItemMap = context.items(); - if ( context.operationName() == OperationName.UPDATE_ITEM) { + if (context.operationName() == OperationName.UPDATE_ITEM) { List attributesToFilter = Arrays.asList(SET_ATTRIBUTE_REF); transformedItemMap = CollectionUtils.filterMap(transformedItemMap, e -> !attributesToFilter.contains(e.getKey())); } @@ -313,4 +1071,147 @@ private DeleteAction deleteFromList(String attributeName) { .build(); } } + + private static final class ListFirstElementUpdateExtension implements DynamoDbEnhancedClientExtension { + @Override + public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + return WriteModification.builder() + .updateExpression(expressionWithSetFirstElement()) + .build(); + } + + private UpdateExpression expressionWithSetFirstElement() { + return UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("requestAttributeList[0]") + .value(":extensionValue") + .putExpressionValue(":extensionValue", AttributeValue.builder().s("extension0").build()) + .build()) + .build(); + } + } + + private static final class ObjectListFirstElementNameUpdateExtension implements DynamoDbEnhancedClientExtension { + @Override + public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { + return WriteModification + .builder() + .updateExpression( + UpdateExpression + .builder() + .addAction( + SetAction.builder() + .path("#objectListAttribute[0].#name") + .value(":extensionObject0") + .putExpressionName("#objectListAttribute", "objectListAttribute") + .putExpressionName("#name", "name") + .putExpressionValue(":extensionObject0", + AttributeValue.builder().s("extensionObject0").build()) + .build()) + .build()) + .build(); + } + } + + @DynamoDbBean + public static final class RecordForUpdateExpressions { + private String id; + private String stringAttribute1; + private List requestAttributeList; + private Long extensionAttribute1; + private Set extensionAttribute2; + private Long incrementedAttribute; + private NestedRecordForUpdateExpressions objectAttribute; + private List objectListAttribute; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbUpdateBehavior(WRITE_IF_NOT_EXISTS) + public String getStringAttribute() { + return stringAttribute1; + } + + public void setStringAttribute(String stringAttribute1) { + this.stringAttribute1 = stringAttribute1; + } + + public List getRequestAttributeList() { + return requestAttributeList; + } + + public void setRequestAttributeList(List requestAttributeList) { + this.requestAttributeList = requestAttributeList; + } + + public Long getExtensionNumberAttribute() { + return extensionAttribute1; + } + + public void setExtensionNumberAttribute(Long extensionAttribute1) { + this.extensionAttribute1 = extensionAttribute1; + } + + public Set getExtensionSetAttribute() { + return extensionAttribute2; + } + + public void setExtensionSetAttribute(Set extensionAttribute2) { + this.extensionAttribute2 = extensionAttribute2; + } + + public Long getIncrementedAttribute() { + return incrementedAttribute; + } + + public RecordForUpdateExpressions setIncrementedAttribute(Long incrementedAttribute) { + this.incrementedAttribute = incrementedAttribute; + return this; + } + + public NestedRecordForUpdateExpressions getObjectAttribute() { + return objectAttribute; + } + + public void setObjectAttribute(NestedRecordForUpdateExpressions objectAttribute) { + this.objectAttribute = objectAttribute; + } + + public List getObjectListAttribute() { + return objectListAttribute; + } + + public void setObjectListAttribute(List objectListAttribute) { + this.objectListAttribute = objectListAttribute; + } + } + + @DynamoDbBean + public static final class NestedRecordForUpdateExpressions { + private String name; + private String city; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordForUpdateExpressions.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordForUpdateExpressions.java deleted file mode 100644 index 2e2c89c8a265..000000000000 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordForUpdateExpressions.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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.UpdateBehavior.WRITE_IF_NOT_EXISTS; - -import java.util.Set; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbUpdateBehavior; - -@DynamoDbBean -public class RecordForUpdateExpressions { - private String id; - private String stringAttribute1; - private Long extensionAttribute1; - private Set extensionAttribute2; - - @DynamoDbPartitionKey - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - @DynamoDbUpdateBehavior(WRITE_IF_NOT_EXISTS) - public String getStringAttribute() { - return stringAttribute1; - } - - public void setStringAttribute(String stringAttribute1) { - this.stringAttribute1 = stringAttribute1; - } - - public Long getExtensionNumberAttribute() { - return extensionAttribute1; - } - - public void setExtensionNumberAttribute(Long extensionAttribute1) { - this.extensionAttribute1 = extensionAttribute1; - } - - public Set getExtensionSetAttribute() { - return extensionAttribute2; - } - - public void setExtensionSetAttribute(Set extensionAttribute2) { - this.extensionAttribute2 = extensionAttribute2; - } -} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTest.java index baaaeeddad6f..26b9428d153d 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTest.java @@ -53,8 +53,10 @@ import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort; import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.update.DeleteAction; +import software.amazon.awssdk.enhanced.dynamodb.update.SetAction; import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @@ -397,6 +399,441 @@ public void generateRequest_withExtensions_conditionAndUpdateExpression() { assertThat(request.expressionAttributeValues(), is(Expression.joinValues(fakeMap, deleteActionMap))); } + @Test + public void generateRequest_withRequestUpdateExpression_only_generatesUpdateItemRequest() { + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("only_from_request") + .value(":v") + .putExpressionValue(":v", AttributeValue.builder().s("req").build()) + .build()) + .build(); + + FakeItem item = createUniqueFakeItem(); + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItem(item, + b -> b.ignoreNulls(true).updateExpression(requestExpression))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + null); + + assertThat(request.updateExpression(), is("SET only_from_request = :v")); + assertThat(request.expressionAttributeValues(), is(singletonMap(":v", AttributeValue.builder().s("req").build()))); + } + + @Test + public void generateRequest_extensionAndRequest_mergeStrategyLegacy_mergesExpressions() { + Map deleteActionMap = singletonMap(":val", AttributeValue.builder().s("s").build()); + DeleteAction deleteAction = DeleteAction.builder().path("attr1") + .value(":val") + .expressionValues(deleteActionMap) + .build(); + UpdateExpression extensionExpression = UpdateExpression.builder().addAction(deleteAction).build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attr2") + .value(":v2") + .putExpressionValue(":v2", AttributeValue.builder().n("1").build()) + .build()) + .build(); + + FakeItem item = createUniqueFakeItem(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItem(item, + b -> b.ignoreNulls(true) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + mockDynamoDbEnhancedClientExtension); + + assertThat(request.updateExpression(), is("SET attr2 = :v2 DELETE attr1 :val")); + assertThat(request.expressionAttributeValues(), is(Expression.joinValues(deleteActionMap, + singletonMap(":v2", numberValue(1))))); + } + + @Test + public void generateRequest_extensionAndRequest_mergeStrategyPrioritize_nonOverlapping_mergesBoth() { + Map deleteActionMap = singletonMap(":val", AttributeValue.builder().s("s").build()); + DeleteAction deleteAction = DeleteAction.builder().path("attr1") + .value(":val") + .expressionValues(deleteActionMap) + .build(); + UpdateExpression extensionExpression = UpdateExpression.builder().addAction(deleteAction).build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attr2") + .value(":v2") + .putExpressionValue(":v2", AttributeValue.builder().n("1").build()) + .build()) + .build(); + + FakeItem item = createUniqueFakeItem(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItem(item, + b -> b.ignoreNulls(true) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + mockDynamoDbEnhancedClientExtension); + + assertThat(request.updateExpression(), is("SET attr2 = :v2 DELETE attr1 :val")); + assertThat(request.expressionAttributeValues(), is(Expression.joinValues(deleteActionMap, + singletonMap(":v2", numberValue(1))))); + } + + @Test + public void generateRequest_extensionAndRequest_mergeStrategyPrioritize_requestWinsOnSamePath() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("subclass_attribute") + .value(":extVal") + .putExpressionValue(":extVal", AttributeValue.builder().s("fromExt").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("subclass_attribute") + .value(":reqVal") + .putExpressionValue(":reqVal", AttributeValue.builder().s("fromReq").build()) + .build()) + .build(); + + FakeItem item = createUniqueFakeItem(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItem(item, + b -> b.ignoreNulls(true) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + mockDynamoDbEnhancedClientExtension); + + assertThat(request.updateExpression(), is("SET subclass_attribute = :reqVal")); + assertThat(request.expressionAttributeValues(), + is(singletonMap(":reqVal", AttributeValue.builder().s("fromReq").build()))); + } + + @Test + public void generateRequest_extensionAndRequest_mergeStrategyLegacy_samePath_keepsBothSetActions() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("subclass_attribute") + .value(":extVal") + .putExpressionValue(":extVal", AttributeValue.builder().s("fromExt").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("subclass_attribute") + .value(":reqVal") + .putExpressionValue(":reqVal", AttributeValue.builder().s("fromReq").build()) + .build()) + .build(); + + FakeItem item = createUniqueFakeItem(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItem(item, + b -> b.ignoreNulls(true) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + mockDynamoDbEnhancedClientExtension); + + assertThat(request.updateExpression(), + is("SET subclass_attribute = :extVal, subclass_attribute = :reqVal")); + Map expectedValues = new HashMap<>(); + expectedValues.put(":extVal", AttributeValue.builder().s("fromExt").build()); + expectedValues.put(":reqVal", AttributeValue.builder().s("fromReq").build()); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + } + + @Test + public void generateRequest_pojoAndRequest_mergeStrategyLegacy_mergesExpressions() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attr_from_request") + .value(":r") + .putExpressionValue(":r", AttributeValue.builder().s("req").build()) + .build()) + .build(); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItemWithSort(item, + b -> b.ignoreNulls(false) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + PRIMARY_CONTEXT, + null); + + String setPojoFirst = + "SET " + OTHER_ATTRIBUTE_1_NAME + " = " + OTHER_ATTRIBUTE_1_VALUE + ", " + + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE + ", attr_from_request = :r"; + String setPojoSecond = + "SET " + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE + ", " + + OTHER_ATTRIBUTE_1_NAME + " = " + OTHER_ATTRIBUTE_1_VALUE + ", attr_from_request = :r"; + Map expectedValues = + Expression.joinValues(expressionValuesFor(OTHER_ATTRIBUTE_1_VALUE, OTHER_ATTRIBUTE_2_VALUE), + singletonMap(":r", AttributeValue.builder().s("req").build())); + + assertThat(request.updateExpression(), either(is(setPojoFirst)).or(is(setPojoSecond))); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expressionNamesFor(OTHER_ATTRIBUTE_1_NAME, OTHER_ATTRIBUTE_2_NAME))); + } + + @Test + public void generateRequest_pojoAndExtension_mergeStrategyLegacy_mergesExpressions() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + Map deleteActionMap = singletonMap(":val", AttributeValue.builder().s("s").build()); + DeleteAction deleteAction = DeleteAction.builder().path("attr1") + .value(":val") + .expressionValues(deleteActionMap) + .build(); + UpdateExpression extensionExpression = UpdateExpression.builder().addAction(deleteAction).build(); + + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItemWithSort(item, + b -> b.ignoreNulls(false) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + PRIMARY_CONTEXT, + mockDynamoDbEnhancedClientExtension); + + String setPojoFirst = + "SET " + OTHER_ATTRIBUTE_1_NAME + " = " + OTHER_ATTRIBUTE_1_VALUE + ", " + + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE + " DELETE attr1 :val"; + String setPojoSecond = + "SET " + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE + ", " + + OTHER_ATTRIBUTE_1_NAME + " = " + OTHER_ATTRIBUTE_1_VALUE + " DELETE attr1 :val"; + Map expectedValues = + Expression.joinValues(expressionValuesFor(OTHER_ATTRIBUTE_1_VALUE, OTHER_ATTRIBUTE_2_VALUE), deleteActionMap); + + assertThat(request.updateExpression(), either(is(setPojoFirst)).or(is(setPojoSecond))); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expressionNamesFor(OTHER_ATTRIBUTE_1_NAME, OTHER_ATTRIBUTE_2_NAME))); + } + + @Test + public void generateRequest_pojoExtensionAndRequest_mergeStrategyLegacy_mergesExpressions() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + Map deleteActionMap = singletonMap(":val", AttributeValue.builder().s("s").build()); + DeleteAction deleteAction = DeleteAction.builder().path("attr1") + .value(":val") + .expressionValues(deleteActionMap) + .build(); + UpdateExpression extensionExpression = UpdateExpression.builder().addAction(deleteAction).build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attr2") + .value(":v2") + .putExpressionValue(":v2", AttributeValue.builder().n("1").build()) + .build()) + .build(); + + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItemWithSort(item, + b -> b.ignoreNulls(false) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + PRIMARY_CONTEXT, + mockDynamoDbEnhancedClientExtension); + + String setPojoFirst = + "SET " + OTHER_ATTRIBUTE_1_NAME + " = " + OTHER_ATTRIBUTE_1_VALUE + ", " + + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE + ", attr2 = :v2 DELETE attr1 :val"; + String setPojoSecond = + "SET " + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE + ", " + + OTHER_ATTRIBUTE_1_NAME + " = " + OTHER_ATTRIBUTE_1_VALUE + ", attr2 = :v2 DELETE attr1 :val"; + Map expectedValues = + Expression.joinValues( + Expression.joinValues(expressionValuesFor(OTHER_ATTRIBUTE_1_VALUE, OTHER_ATTRIBUTE_2_VALUE), deleteActionMap), + singletonMap(":v2", numberValue(1))); + + assertThat(request.updateExpression(), either(is(setPojoFirst)).or(is(setPojoSecond))); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expressionNamesFor(OTHER_ATTRIBUTE_1_NAME, OTHER_ATTRIBUTE_2_NAME))); + } + + @Test + public void generateRequest_pojoAndRequest_mergeStrategyPrioritize_requestWinsOverlappingPath() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("other_attribute_1") + .value(":reqVal") + .putExpressionValue(":reqVal", AttributeValue.builder().s("fromReq").build()) + .build()) + .build(); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItemWithSort(item, + b -> b.ignoreNulls(false) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + PRIMARY_CONTEXT, + null); + + assertThat(request.updateExpression(), + is("SET other_attribute_1 = :reqVal, " + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE)); + assertThat(request.expressionAttributeValues(), + is(Expression.joinValues(singletonMap(":reqVal", AttributeValue.builder().s("fromReq").build()), + expressionValuesFor(OTHER_ATTRIBUTE_2_VALUE)))); + assertThat(request.expressionAttributeNames(), is(expressionNamesFor(OTHER_ATTRIBUTE_2_NAME))); + } + + @Test + public void generateRequest_pojoAndExtension_mergeStrategyPrioritize_extensionWinsOverlappingPath() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("other_attribute_1") + .value(":extVal") + .putExpressionValue(":extVal", AttributeValue.builder().s("fromExt").build()) + .build()) + .build(); + + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItemWithSort(item, + b -> b.ignoreNulls(false) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + PRIMARY_CONTEXT, + mockDynamoDbEnhancedClientExtension); + + assertThat(request.updateExpression(), + is("SET other_attribute_1 = :extVal, " + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE)); + assertThat(request.expressionAttributeValues(), + is(Expression.joinValues(singletonMap(":extVal", AttributeValue.builder().s("fromExt").build()), + expressionValuesFor(OTHER_ATTRIBUTE_2_VALUE)))); + assertThat(request.expressionAttributeNames(), is(expressionNamesFor(OTHER_ATTRIBUTE_2_NAME))); + } + + @Test + public void generateRequest_pojoExtensionAndRequest_mergeStrategyPrioritize_resolvesPathPriority() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("other_attribute_1") + .value(":extVal") + .putExpressionValue(":extVal", AttributeValue.builder().s("fromExt").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("isolated_attr") + .value(":reqVal") + .putExpressionValue(":reqVal", AttributeValue.builder().s("fromReq").build()) + .build()) + .build(); + + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(requestFakeItemWithSort(item, + b -> b.ignoreNulls(false) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE))); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + PRIMARY_CONTEXT, + mockDynamoDbEnhancedClientExtension); + + assertThat(request.updateExpression(), + is("SET isolated_attr = :reqVal, other_attribute_1 = :extVal, " + + OTHER_ATTRIBUTE_2_NAME + " = " + OTHER_ATTRIBUTE_2_VALUE)); + Map expectedValues = new HashMap<>(); + expectedValues.put(":reqVal", AttributeValue.builder().s("fromReq").build()); + expectedValues.put(":extVal", AttributeValue.builder().s("fromExt").build()); + expectedValues.put(OTHER_ATTRIBUTE_2_VALUE, EXPRESSION_VALUES.get(OTHER_ATTRIBUTE_2_VALUE)); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expressionNamesFor(OTHER_ATTRIBUTE_2_NAME))); + } + @Test public void generateRequest_withExtensions_conflictingExpressionValue_throwsRuntimeException() { FakeItem baseFakeItem = createUniqueFakeItem(); diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTransactTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTransactTest.java index 2c72100138ca..ce339498d05e 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTransactTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTransactTest.java @@ -16,15 +16,21 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.operations; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.either; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import static java.util.Collections.singletonMap; +import static org.mockito.Mockito.when; import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem.createUniqueFakeItem; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort.createUniqueFakeItemWithSort; +import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.numberValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import java.util.function.Consumer; import org.junit.Test; @@ -32,11 +38,19 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; +import software.amazon.awssdk.enhanced.dynamodb.Expression; import software.amazon.awssdk.enhanced.dynamodb.OperationContext; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItemWithSort; import software.amazon.awssdk.enhanced.dynamodb.model.TransactUpdateItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.update.DeleteAction; +import software.amazon.awssdk.enhanced.dynamodb.update.SetAction; +import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ReturnValue; import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem; @@ -46,6 +60,10 @@ @RunWith(MockitoJUnitRunner.class) public class UpdateItemOperationTransactTest { private static final String TABLE_NAME = "table-name"; + private static final String OA1_NAME = "#AMZN_MAPPED_other_attribute_1"; + private static final String OA2_NAME = "#AMZN_MAPPED_other_attribute_2"; + private static final String OA1_VAL = ":AMZN_MAPPED_other_attribute_1"; + private static final String OA2_VAL = ":AMZN_MAPPED_other_attribute_2"; @Mock private DynamoDbEnhancedClientExtension mockDynamoDbEnhancedClientExtension; @@ -155,6 +173,550 @@ public void generateTransactWriteItem_returnValuesOnConditionCheckFailure_genera verify(updateItemOperation).generateRequest(FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); } + @Test + public void generateTransactWriteItem_withSetAction_includesSetUpdateExpression() { + FakeItem fakeItem = createUniqueFakeItem(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction( + SetAction.builder() + .path("attr") + .value(":value") + .putExpressionValue(":value", AttributeValue.builder().s("updated").build()) + .build()) + .build(); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(TransactUpdateItemEnhancedRequest.builder(FakeItem.class) + .item(fakeItem) + .ignoreNulls(true) + .updateExpression(requestExpression) + .build()); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + assertThat(request.updateExpression(), is("SET attr = :value")); + assertThat(request.expressionAttributeValues(), is(Collections.singletonMap(":value", stringValue("updated")))); + assertThat(transactWriteItem.update().updateExpression(), is("SET attr = :value")); + } + + @Test + public void generateTransactWriteItem_withRequestUpdateExpression_only_generatesUpdateItemRequest() { + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("only_from_request") + .value(":v") + .putExpressionValue(":v", AttributeValue.builder().s("req").build()) + .build()) + .build(); + + FakeItem fakeItem = createUniqueFakeItem(); + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(TransactUpdateItemEnhancedRequest.builder(FakeItem.class) + .item(fakeItem) + .ignoreNulls(true) + .updateExpression(requestExpression) + .build()); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + context, + null); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItem.getTableSchema(), context, null); + + assertThat(request.updateExpression(), is("SET only_from_request = :v")); + assertThat(request.expressionAttributeValues(), is(singletonMap(":v", AttributeValue.builder().s("req").build()))); + assertThat(transactWriteItem.update().updateExpression(), is("SET only_from_request = :v")); + assertThat(transactWriteItem.update().expressionAttributeValues(), + is(singletonMap(":v", AttributeValue.builder().s("req").build()))); + } + + @Test + public void generateTransactWriteItem_extensionAndRequest_mergeStrategyLegacy_mergesExpressions() { + Map deleteActionMap = singletonMap(":val", AttributeValue.builder().s("s").build()); + DeleteAction deleteAction = DeleteAction.builder().path("attr1") + .value(":val") + .expressionValues(deleteActionMap) + .build(); + UpdateExpression extensionExpression = UpdateExpression.builder().addAction(deleteAction).build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attr2") + .value(":v2") + .putExpressionValue(":v2", AttributeValue.builder().n("1").build()) + .build()) + .build(); + + FakeItem fakeItem = createUniqueFakeItem(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(TransactUpdateItemEnhancedRequest.builder(FakeItem.class) + .item(fakeItem) + .ignoreNulls(true) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY) + .build()); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + Map expectedValues = Expression.joinValues(deleteActionMap, singletonMap(":v2", numberValue(1))); + assertThat(request.updateExpression(), is("SET attr2 = :v2 DELETE attr1 :val")); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().updateExpression(), is("SET attr2 = :v2 DELETE attr1 :val")); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + } + + @Test + public void generateTransactWriteItem_extensionAndRequest_mergeStrategyPrioritize_nonOverlapping_mergesBoth() { + Map deleteActionMap = singletonMap(":val", AttributeValue.builder().s("s").build()); + DeleteAction deleteAction = DeleteAction.builder().path("attr1") + .value(":val") + .expressionValues(deleteActionMap) + .build(); + UpdateExpression extensionExpression = UpdateExpression.builder().addAction(deleteAction).build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attr2") + .value(":v2") + .putExpressionValue(":v2", AttributeValue.builder().n("1").build()) + .build()) + .build(); + + FakeItem fakeItem = createUniqueFakeItem(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(TransactUpdateItemEnhancedRequest.builder(FakeItem.class) + .item(fakeItem) + .ignoreNulls(true) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE) + .build()); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + Map expectedValues = Expression.joinValues(deleteActionMap, singletonMap(":v2", numberValue(1))); + assertThat(request.updateExpression(), is("SET attr2 = :v2 DELETE attr1 :val")); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().updateExpression(), is("SET attr2 = :v2 DELETE attr1 :val")); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + } + + @Test + public void generateTransactWriteItem_extensionAndRequest_mergeStrategyPrioritize_requestWinsOnSamePath() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("subclass_attribute") + .value(":extVal") + .putExpressionValue(":extVal", AttributeValue.builder().s("fromExt").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("subclass_attribute") + .value(":reqVal") + .putExpressionValue(":reqVal", AttributeValue.builder().s("fromReq").build()) + .build()) + .build(); + + FakeItem fakeItem = createUniqueFakeItem(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(TransactUpdateItemEnhancedRequest.builder(FakeItem.class) + .item(fakeItem) + .ignoreNulls(true) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE) + .build()); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + Map expectedValues = + singletonMap(":reqVal", AttributeValue.builder().s("fromReq").build()); + assertThat(request.updateExpression(), is("SET subclass_attribute = :reqVal")); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().updateExpression(), is("SET subclass_attribute = :reqVal")); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + } + + @Test + public void generateTransactWriteItem_extensionAndRequest_mergeStrategyLegacy_samePath_keepsBothSetActions() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("subclass_attribute") + .value(":extVal") + .putExpressionValue(":extVal", AttributeValue.builder().s("fromExt").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("subclass_attribute") + .value(":reqVal") + .putExpressionValue(":reqVal", AttributeValue.builder().s("fromReq").build()) + .build()) + .build(); + + FakeItem fakeItem = createUniqueFakeItem(); + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(TransactUpdateItemEnhancedRequest.builder(FakeItem.class) + .item(fakeItem) + .ignoreNulls(true) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY) + .build()); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItem.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + Map expectedValues = new HashMap<>(); + expectedValues.put(":extVal", AttributeValue.builder().s("fromExt").build()); + expectedValues.put(":reqVal", AttributeValue.builder().s("fromReq").build()); + assertThat(request.updateExpression(), + is("SET subclass_attribute = :extVal, subclass_attribute = :reqVal")); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().updateExpression(), + is("SET subclass_attribute = :extVal, subclass_attribute = :reqVal")); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + } + + @Test + public void generateTransactWriteItem_pojoAndRequest_mergeStrategyLegacy_mergesExpressions() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attr_from_request") + .value(":r") + .putExpressionValue(":r", AttributeValue.builder().s("req").build()) + .build()) + .build(); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(transactRequestWithSort(item, + b -> b.ignoreNulls(false) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY))); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + context, + null); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItemWithSort.getTableSchema(), context, null); + + String setPojoFirst = + "SET " + OA1_NAME + " = " + OA1_VAL + ", " + OA2_NAME + " = " + OA2_VAL + ", attr_from_request = :r"; + String setPojoSecond = + "SET " + OA2_NAME + " = " + OA2_VAL + ", " + OA1_NAME + " = " + OA1_VAL + ", attr_from_request = :r"; + Map expectedValues = + Expression.joinValues(pojoAttributeValues(), singletonMap(":r", AttributeValue.builder().s("req").build())); + Map expectedNames = pojoExpressionNames(); + + assertThat(request.updateExpression(), either(is(setPojoFirst)).or(is(setPojoSecond))); + assertThat(transactWriteItem.update().updateExpression(), is(request.updateExpression())); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expectedNames)); + assertThat(transactWriteItem.update().expressionAttributeNames(), is(expectedNames)); + } + + @Test + public void generateTransactWriteItem_pojoAndExtension_mergeStrategyLegacy_mergesExpressions() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + Map deleteActionMap = singletonMap(":val", AttributeValue.builder().s("s").build()); + DeleteAction deleteAction = DeleteAction.builder().path("attr1") + .value(":val") + .expressionValues(deleteActionMap) + .build(); + UpdateExpression extensionExpression = UpdateExpression.builder().addAction(deleteAction).build(); + + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(transactRequestWithSort(item, + b -> b.ignoreNulls(false) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY))); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItemWithSort.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + String setPojoFirst = + "SET " + OA1_NAME + " = " + OA1_VAL + ", " + OA2_NAME + " = " + OA2_VAL + " DELETE attr1 :val"; + String setPojoSecond = + "SET " + OA2_NAME + " = " + OA2_VAL + ", " + OA1_NAME + " = " + OA1_VAL + " DELETE attr1 :val"; + Map expectedValues = Expression.joinValues(pojoAttributeValues(), deleteActionMap); + Map expectedNames = pojoExpressionNames(); + + assertThat(request.updateExpression(), either(is(setPojoFirst)).or(is(setPojoSecond))); + assertThat(transactWriteItem.update().updateExpression(), is(request.updateExpression())); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expectedNames)); + assertThat(transactWriteItem.update().expressionAttributeNames(), is(expectedNames)); + } + + @Test + public void generateTransactWriteItem_pojoExtensionAndRequest_mergeStrategyLegacy_mergesExpressions() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + Map deleteActionMap = singletonMap(":val", AttributeValue.builder().s("s").build()); + DeleteAction deleteAction = DeleteAction.builder().path("attr1") + .value(":val") + .expressionValues(deleteActionMap) + .build(); + UpdateExpression extensionExpression = UpdateExpression.builder().addAction(deleteAction).build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attr2") + .value(":v2") + .putExpressionValue(":v2", AttributeValue.builder().n("1").build()) + .build()) + .build(); + + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(transactRequestWithSort(item, + b -> b.ignoreNulls(false) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.LEGACY))); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItemWithSort.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + String setPojoFirst = + "SET " + OA1_NAME + " = " + OA1_VAL + ", " + OA2_NAME + " = " + OA2_VAL + ", attr2 = :v2 DELETE attr1 :val"; + String setPojoSecond = + "SET " + OA2_NAME + " = " + OA2_VAL + ", " + OA1_NAME + " = " + OA1_VAL + ", attr2 = :v2 DELETE attr1 :val"; + Map expectedValues = + Expression.joinValues(Expression.joinValues(pojoAttributeValues(), deleteActionMap), + singletonMap(":v2", numberValue(1))); + Map expectedNames = pojoExpressionNames(); + + assertThat(request.updateExpression(), either(is(setPojoFirst)).or(is(setPojoSecond))); + assertThat(transactWriteItem.update().updateExpression(), is(request.updateExpression())); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expectedNames)); + assertThat(transactWriteItem.update().expressionAttributeNames(), is(expectedNames)); + } + + @Test + public void generateTransactWriteItem_pojoAndRequest_mergeStrategyPrioritize_requestWinsOverlappingPath() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("other_attribute_1") + .value(":reqVal") + .putExpressionValue(":reqVal", AttributeValue.builder().s("fromReq").build()) + .build()) + .build(); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(transactRequestWithSort(item, + b -> b.ignoreNulls(false) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE))); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + context, + null); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItemWithSort.getTableSchema(), context, null); + + String expectedExpr = "SET other_attribute_1 = :reqVal, " + OA2_NAME + " = " + OA2_VAL; + Map expectedValues = + Expression.joinValues(singletonMap(":reqVal", AttributeValue.builder().s("fromReq").build()), + singletonMap(OA2_VAL, AttributeValue.builder().s("value-2").build())); + Map expectedNames = singletonMap(OA2_NAME, "other_attribute_2"); + + assertThat(request.updateExpression(), is(expectedExpr)); + assertThat(transactWriteItem.update().updateExpression(), is(expectedExpr)); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expectedNames)); + assertThat(transactWriteItem.update().expressionAttributeNames(), is(expectedNames)); + } + + @Test + public void generateTransactWriteItem_pojoAndExtension_mergeStrategyPrioritize_extensionWinsOverlappingPath() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("other_attribute_1") + .value(":extVal") + .putExpressionValue(":extVal", AttributeValue.builder().s("fromExt").build()) + .build()) + .build(); + + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(transactRequestWithSort(item, + b -> b.ignoreNulls(false) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE))); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItemWithSort.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + String expectedExpr = "SET other_attribute_1 = :extVal, " + OA2_NAME + " = " + OA2_VAL; + Map expectedValues = + Expression.joinValues(singletonMap(":extVal", AttributeValue.builder().s("fromExt").build()), + singletonMap(OA2_VAL, AttributeValue.builder().s("value-2").build())); + Map expectedNames = singletonMap(OA2_NAME, "other_attribute_2"); + + assertThat(request.updateExpression(), is(expectedExpr)); + assertThat(transactWriteItem.update().updateExpression(), is(expectedExpr)); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expectedNames)); + assertThat(transactWriteItem.update().expressionAttributeNames(), is(expectedNames)); + } + + @Test + public void generateTransactWriteItem_pojoExtensionAndRequest_mergeStrategyPrioritize_resolvesPathPriority() { + FakeItemWithSort item = createUniqueFakeItemWithSort(); + item.setOtherAttribute1("value-1"); + item.setOtherAttribute2("value-2"); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("other_attribute_1") + .value(":extVal") + .putExpressionValue(":extVal", AttributeValue.builder().s("fromExt").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("isolated_attr") + .value(":reqVal") + .putExpressionValue(":reqVal", AttributeValue.builder().s("fromReq").build()) + .build()) + .build(); + + when(mockDynamoDbEnhancedClientExtension.beforeWrite(any(DynamoDbExtensionContext.BeforeWrite.class))) + .thenReturn(WriteModification.builder().updateExpression(extensionExpression).build()); + + UpdateItemOperation updateItemOperation = + UpdateItemOperation.create(transactRequestWithSort(item, + b -> b.ignoreNulls(false) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy( + UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE))); + OperationContext context = DefaultOperationContext.create(TABLE_NAME, TableMetadata.primaryIndexName()); + + UpdateItemRequest request = updateItemOperation.generateRequest(FakeItemWithSort.getTableSchema(), + context, + mockDynamoDbEnhancedClientExtension); + TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem( + FakeItemWithSort.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); + + String expectedExpr = + "SET isolated_attr = :reqVal, other_attribute_1 = :extVal, " + OA2_NAME + " = " + OA2_VAL; + Map expectedValues = new HashMap<>(); + expectedValues.put(":reqVal", AttributeValue.builder().s("fromReq").build()); + expectedValues.put(":extVal", AttributeValue.builder().s("fromExt").build()); + expectedValues.put(OA2_VAL, AttributeValue.builder().s("value-2").build()); + Map expectedNames = singletonMap(OA2_NAME, "other_attribute_2"); + + assertThat(request.updateExpression(), is(expectedExpr)); + assertThat(transactWriteItem.update().updateExpression(), is(expectedExpr)); + assertThat(request.expressionAttributeValues(), is(expectedValues)); + assertThat(transactWriteItem.update().expressionAttributeValues(), is(expectedValues)); + assertThat(request.expressionAttributeNames(), is(expectedNames)); + assertThat(transactWriteItem.update().expressionAttributeNames(), is(expectedNames)); + } private UpdateItemRequest ddbRequest(Map keys, Consumer modify) { UpdateItemRequest.Builder builder = ddbBaseRequestBuilder(keys); @@ -172,4 +734,26 @@ private UpdateItemRequest.Builder ddbBaseRequestBuilder(Map transactRequestWithSort( + FakeItemWithSort item, + Consumer> modify) { + TransactUpdateItemEnhancedRequest.Builder builder = + TransactUpdateItemEnhancedRequest.builder(FakeItemWithSort.class).item(item); + modify.accept(builder); + return builder.build(); + } + + private static Map pojoAttributeValues() { + Map m = new HashMap<>(); + m.put(OA1_VAL, AttributeValue.builder().s("value-1").build()); + m.put(OA2_VAL, AttributeValue.builder().s("value-2").build()); + return m; + } + + private static Map pojoExpressionNames() { + Map m = new HashMap<>(); + m.put(OA1_NAME, "other_attribute_1"); + m.put(OA2_NAME, "other_attribute_2"); + return m; + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverterTest.java index e3d389fd5261..755bb39ab85b 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverterTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverterTest.java @@ -408,6 +408,11 @@ void findAttributeNames_emptyExpression_returnEmptyList() { assertThat(attributes).isEmpty(); } + @Test + void findAttributeNames_nullExpression_returnsEmptyList() { + assertThat(UpdateExpressionConverter.findAttributeNames(null)).isEmpty(); + } + @Test void findAttributeNames_noComposedNames_noTokens() { UpdateExpression updateExpression = createUpdateExpression(addAction("attribute1", string("val1"), null, VALUE_TOKEN), @@ -433,7 +438,7 @@ void findAttributeNames_noComposedNames_duplicates() { UpdateExpression updateExpression = createUpdateExpression(addAction("attribute1", string("val1"), null, VALUE_TOKEN), deleteAction("attribute1", string("val2"), KEY_TOKEN, VALUE_TOKEN)); List attributes = UpdateExpressionConverter.findAttributeNames(updateExpression); - assertThat(attributes).containsExactlyInAnyOrder("attribute1", "attribute1"); + assertThat(attributes).containsExactly("attribute1"); } @Test @@ -461,7 +466,7 @@ void findAttributeNames_composedNames_duplicates() { UpdateExpression updateExpression = createUpdateExpression(addAction("attribute1[1]", string("val1"), null, VALUE_TOKEN), deleteAction("attribute1.nested", string("val2"), KEY_TOKEN, VALUE_TOKEN)); List attributes = UpdateExpressionConverter.findAttributeNames(updateExpression); - assertThat(attributes).containsExactlyInAnyOrder("attribute1", "attribute1"); + assertThat(attributes).containsExactly("attribute1"); } private static RemoveAction removeAction(String attributeName, String keyToken) { diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolverTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolverTest.java new file mode 100644 index 000000000000..f67cc1da88ea --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolverTest.java @@ -0,0 +1,1559 @@ +/* + * 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.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.LEGACY; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.update.AddAction; +import software.amazon.awssdk.enhanced.dynamodb.update.DeleteAction; +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; + +/** + * Unit tests for {@link UpdateExpressionResolver}. + *

+ * Naming convention (merge strategy is reflected in each test name): + *

    + *
  • {@code resolve_legacy_*} — {@link software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy#LEGACY} + * (default; also when merge strategy is unset or {@code null})
  • + *
  • {@code resolve_prioritizeHigherSource_*} — + * {@link software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}
  • + *
+ */ +public class UpdateExpressionResolverTest { + + private static final TableMetadata TABLE_METADATA = StaticTableMetadata.builder().build(); + + @Test + public void build_nonKeyAttributesWithoutTableMetadata_throwsNullPointerException() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().s("v").build()); + + assertThatThrownBy(() -> UpdateExpressionResolver.builder() + .nonKeyAttributes(itemMap) + .build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("tableMetadata"); + } + + @Test + public void build_emptyNonKeyAttributesWithoutTableMetadata_succeeds() { + UpdateExpressionResolver resolver = + UpdateExpressionResolver + .builder() + .nonKeyAttributes(Collections.emptyMap()) + .requestExpression( + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("a") + .value(":v") + .putExpressionValue(":v", AttributeValue.builder().s("x").build()) + .build()) + .build()) + .build(); + assertThat(resolver.resolve()).isNotNull(); + } + + // -------------------------------------------------------------- + // LEGACY — default merge strategy (order map, extension, request) + // -------------------------------------------------------------- + + @Test + public void resolve_legacy_emptyInputs_returnsNull() { + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(Collections.emptyMap()) + .build(); + + UpdateExpression result = resolver.resolve(); + + assertThat(result).isNull(); + } + + @Test + public void resolve_legacy_nonNullAttributes_generatesSetActions() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + itemMap.put("attrTwo", AttributeValue.builder().n("2").build()); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .build(); + + UpdateExpression result = resolver.resolve(); + + assertThat(result).isNotNull(); + assertThat(result.removeActions()).isEmpty(); + assertThat(result.addActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.setActions()).hasSize(2).containsExactlyInAnyOrder( + SetAction.builder() + .path("#AMZN_MAPPED_attr") + .value(":AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .putExpressionValue(":AMZN_MAPPED_attr", AttributeValue.builder().s("attrValue").build()) + .build(), + + SetAction.builder() + .path("#AMZN_MAPPED_attrTwo") + .value(":AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") + .putExpressionValue(":AMZN_MAPPED_attrTwo", AttributeValue.builder().n("2").build()) + .build()); + } + + @Test + public void resolve_legacy_nullAttributes_generatesRemoveActions() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().nul(true).build()); + itemMap.put("attrTwo", AttributeValue.builder().nul(true).build()); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .build(); + + UpdateExpression result = resolver.resolve(); + + assertThat(result).isNotNull(); + assertThat(result.setActions()).isEmpty(); + assertThat(result.addActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.removeActions()).hasSize(2).containsExactlyInAnyOrder( + RemoveAction.builder() + .path("#AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .build(), + + RemoveAction.builder() + .path("#AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") + .build()); + } + + @Test + public void resolve_legacy_mixedAttributes_generatesBothActions() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + itemMap.put("attrToRemove", AttributeValue.builder().nul(true).build()); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .build(); + + UpdateExpression result = resolver.resolve(); + + assertThat(result).isNotNull(); + assertThat(result.addActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.setActions()).isEqualTo(Collections.singletonList( + SetAction.builder() + .path("#AMZN_MAPPED_attr") + .value(":AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .putExpressionValue(":AMZN_MAPPED_attr", AttributeValue.builder().s("attrValue").build()) + .build())); + + assertThat(result.removeActions()).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_attrToRemove") + .putExpressionName("#AMZN_MAPPED_attrToRemove", "attrToRemove") + .build())); + } + + @Test + public void resolve_legacy_withPojoAndExtensionExpressions_mergesActions() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(AddAction.builder() + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", + AttributeValue.builder().n("1").build()) + .build()) + .build(); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .build(); + + UpdateExpression result = resolver.resolve(); + + assertThat(result).isNotNull(); + assertThat(result.removeActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.setActions()).isEqualTo(Collections.singletonList( + SetAction.builder() + .path("#AMZN_MAPPED_attr") + .value(":AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .putExpressionValue(":AMZN_MAPPED_attr", AttributeValue.builder().s("attrValue").build()) + .build())); + + assertThat(result.addActions()).isEqualTo(Collections.singletonList( + AddAction.builder() + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", AttributeValue.builder().n("1").build()) + .build())); + } + + @Test + public void resolve_legacy_withAllExpressionTypes_mergesActions() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(AddAction.builder() + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", + AttributeValue.builder().s("extAttrValue").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("reqAttr") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", + AttributeValue.builder().s("reqAttrValue").build()) + .build()) + .build(); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .build(); + + UpdateExpression result = resolver.resolve(); + + assertThat(result).isNotNull(); + assertThat(result.removeActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.setActions()).hasSize(2).containsExactlyInAnyOrder( + SetAction.builder() + .path("#AMZN_MAPPED_attr") + .value(":AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .putExpressionValue(":AMZN_MAPPED_attr", AttributeValue.builder().s("attrValue").build()) + .build(), + SetAction.builder() + .path("reqAttr") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("reqAttrValue").build()) + .build()); + + assertThat(result.addActions()).isEqualTo(Collections.singletonList( + AddAction.builder() + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", AttributeValue.builder().s("extAttrValue").build()) + .build())); + } + + @Test + public void resolve_legacy_attributeUsedInOtherExpression_filteredOutFromRemoveActions() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().nul(true).build()); + itemMap.put("attrTwo", AttributeValue.builder().nul(true).build()); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr") + .value(":reqAttrValue") + .putExpressionName("#attr", "attr") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("reqAttrValue").build()) + .build()) + .build(); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .requestExpression(requestExpression) + .build(); + + UpdateExpression result = resolver.resolve(); + + assertThat(result).isNotNull(); + assertThat(result.addActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.setActions()).isEqualTo(Collections.singletonList( + SetAction.builder() + .path("attr") + .value(":reqAttrValue") + .putExpressionName("#attr", "attr") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("reqAttrValue").build()) + .build())); + + // Only attrTwo remains for REMOVE because attr is referenced by request expression. + assertThat(result.removeActions()).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") + .build())); + } + + @Test + public void resolve_legacy_builder_preservesConfiguredFields() { + Map itemMap = new HashMap<>(); + UpdateExpression extensionExpr = UpdateExpression.builder().build(); + UpdateExpression requestExpr = UpdateExpression.builder().build(); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpr) + .requestExpression(requestExpr) + .build(); + + assertThat(resolver.tableMetadata()).isSameAs(TABLE_METADATA); + assertThat(resolver.nonKeyAttributes()).isEmpty(); + assertThat(resolver.extensionExpression()).isSameAs(extensionExpr); + assertThat(resolver.requestExpression()).isSameAs(requestExpr); + assertThat(resolver.updateExpressionMergeStrategy()).isEqualTo(LEGACY); + } + + @Test + public void resolve_legacy_builder_whenNonKeyAttributesNull_defaultsToEmptyMap() { + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(null) + .build(); + + assertThat(resolver.nonKeyAttributes()).isEmpty(); + assertThat(resolver.resolve()).isNull(); + } + + // --------------------------------------------------------- + // LEGACY — no non-key order map / no request expression (extension only) + // --------------------------------------------------------- + + @Test + public void resolve_legacy_whenOnlyExtensionActionPresent_returnsExtensionExpression() { + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(AddAction.builder() + .path("counter") + .value(":inc") + .putExpressionValue(":inc", AttributeValue.builder().n("1").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .build() + .resolve(); + + assertThat(result).isEqualTo(extensionExpression); + } + + // ------------------------------------------------------- + // LEGACY — overlapping paths are concatenated + // ------------------------------------------------------- + + @Test + public void resolve_legacy_overlappingPojoAndRequest_concatenatesBothActions() { + Map itemMap = new HashMap<>(); + itemMap.put("counter", AttributeValue.builder().n("10").build()); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .requestExpression(requestExpression) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("#AMZN_MAPPED_counter") + .value(":AMZN_MAPPED_counter") + .putExpressionName("#AMZN_MAPPED_counter", "counter") + .putExpressionValue(":AMZN_MAPPED_counter", AttributeValue.builder().n("10").build()) + .build(), + SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) + .build()); + } + + @Test + public void resolve_legacy_overlappingExtensionAndRequest_concatenatesBothActions() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("10").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("10").build()) + .build(), + SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) + .build()); + } + + @Test + public void resolve_legacy_mergeStrategyNull_defaultsToConcatenation() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("10").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(null) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("10").build()) + .build(), + SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — three-source list overlap + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_listTouchedByPojoExtensionAndRequest_keepsNonOverlappingExtensionAndRequestIndices() { + Map itemMap = new HashMap<>(); + itemMap.put("list", AttributeValue.builder().l(AttributeValue.builder().s("pojo").build()).build()); + + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("list[0]") + .value(":extensionValue") + .putExpressionValue(":extensionValue", AttributeValue.builder().s("ext-value").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("list[1]") + .value(":requestValue") + .putExpressionValue(":requestValue", AttributeValue.builder().s("req-value").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("list[0]") + .value(":extensionValue") + .putExpressionValue(":extensionValue", AttributeValue.builder().s("ext-value").build()) + .build(), + SetAction.builder() + .path("list[1]") + .value(":requestValue") + .putExpressionValue(":requestValue", AttributeValue.builder().s("req-value").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — sibling list indices (same top-level name) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_siblingListIndicesUnderSameAttribute_keepsExtensionAndRequest() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("list[0]") + .value(":v0") + .putExpressionValue(":v0", AttributeValue.builder().s("v0").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("list[1]") + .value(":v1") + .putExpressionValue(":v1", AttributeValue.builder().s("v1").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("list[0]") + .value(":v0") + .putExpressionValue(":v0", AttributeValue.builder().s("v0").build()) + .build(), + SetAction.builder() + .path("list[1]") + .value(":v1") + .putExpressionValue(":v1", AttributeValue.builder().s("v1").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — exact same scalar path + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_identicalPathFromThreeSources_keepsRequestSetOnly() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("10").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — object parent/child overlap + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_objectRootVersusNestedRequestPath_keepsRequestNestedSetOnly() { + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("customer") + .value(":customer") + .putExpressionValue(":customer", AttributeValue.builder().m(Collections.emptyMap()).build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("customer.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("john").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("customer.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("john").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — list-of-objects parent/child + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_objectListRootVersusNestedRequestPath_keepsRequestNestedSetOnly() { + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("orders[0]") + .value(":order0") + .putExpressionValue(":order0", AttributeValue.builder().m(Collections.emptyMap()).build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("orders[0].id") + .value(":id") + .putExpressionValue(":id", AttributeValue.builder().s("orderId").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("orders[0].id") + .value(":id") + .putExpressionValue(":id", AttributeValue.builder().s("orderId").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — nested object paths under same top-level name + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_objectNestedSiblingsUnderSameTopLevelName_keepsExtensionAndRequest() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("customer.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("john").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("customer.address.city") + .value(":city") + .putExpressionValue(":city", AttributeValue.builder().s("london").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("customer.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("john").build()) + .build(), + SetAction.builder() + .path("customer.address.city") + .value(":city") + .putExpressionValue(":city", AttributeValue.builder().s("london").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — POJO-extension only (no request) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_pojoAndExtensionShareAttributeWithoutRequest_keepsExtensionActionOnly() { + Map itemMap = new HashMap<>(); + itemMap.put("counter", AttributeValue.builder().n("10").build()); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("20").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("20").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — extension-request only (no POJO) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_extensionAndRequestShareAttributeWithoutPojo_keepsRequestActionOnly() { + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("status") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().s("ext-val").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("status") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().s("req-val").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("status") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().s("req-val").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — completely disjoint sources + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_allSourcesDisjointPaths_keepsEveryAction() { + Map itemMap = new HashMap<>(); + itemMap.put("pojoAttr", AttributeValue.builder().s("1").build()); + + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", AttributeValue.builder().s("2").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("reqAttr") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("3").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("#AMZN_MAPPED_pojoAttr") + .value(":AMZN_MAPPED_pojoAttr") + .putExpressionName("#AMZN_MAPPED_pojoAttr", "pojoAttr") + .putExpressionValue(":AMZN_MAPPED_pojoAttr", AttributeValue.builder().s("1").build()) + .build(), + SetAction.builder() + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", AttributeValue.builder().s("2").build()) + .build(), + SetAction.builder() + .path("reqAttr") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("3").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — POJO REMOVE vs request SET + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_pojoRemoveVsRequestSet_keepsRequestSetOnly() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().nul(true).build()); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("new-value").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("attr") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("new-value").build()) + .build()); + assertThat(result.removeActions()).isEmpty(); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — three-source exact same path + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_threeSourcesSamePath_keepsRequestOnly() { + Map itemMap = new HashMap<>(); + itemMap.put("counter", AttributeValue.builder().n("1").build()); + + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("2").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("3").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("3").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — expression attribute names (#list → list) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_expressionNamePlaceholder_nonOverlappingListIndices_keepsExtensionAndRequest() { + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("#l[0]") + .value(":v0") + .putExpressionName("#l", "list") + .putExpressionValue(":v0", AttributeValue.builder().s("ext").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("list[1]") + .value(":v1") + .putExpressionValue(":v1", AttributeValue.builder().s("req").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("#l[0]") + .value(":v0") + .putExpressionName("#l", "list") + .putExpressionValue(":v0", AttributeValue.builder().s("ext").build()) + .build(), + SetAction.builder() + .path("list[1]") + .value(":v1") + .putExpressionValue(":v1", AttributeValue.builder().s("req").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — multiple request actions on one top-level name + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_requestMultipleActionsOnSameTopLevelName_allKept() { + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("list[2]") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().s("ext").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("list[0]") + .value(":a") + .putExpressionValue(":a", AttributeValue.builder().s("a").build()) + .build()) + .addAction(SetAction.builder() + .path("list[1]") + .value(":b") + .putExpressionValue(":b", AttributeValue.builder().s("b").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("list[2]") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().s("ext").build()) + .build(), + SetAction.builder() + .path("list[0]") + .value(":a") + .putExpressionValue(":a", AttributeValue.builder().s("a").build()) + .build(), + SetAction.builder() + .path("list[1]") + .value(":b") + .putExpressionValue(":b", AttributeValue.builder().s("b").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — extension vs request on different nested paths (same first segment) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_nestedPathsSameFirstSegment_keepsNonOverlappingSiblings() { + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("a.b") + .value(":b") + .putExpressionValue(":b", AttributeValue.builder().s("from-ext").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("a.c") + .value(":c") + .putExpressionValue(":c", AttributeValue.builder().s("from-req").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("a.b") + .value(":b") + .putExpressionValue(":b", AttributeValue.builder().s("from-ext").build()) + .build(), + SetAction.builder() + .path("a.c") + .value(":c") + .putExpressionValue(":c", AttributeValue.builder().s("from-req").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — sibling map paths under same root (profile.name vs profile.city) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_extensionProfileNameAndRequestProfileCity_keepsBoth() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("profile.name") + .value(":n") + .putExpressionValue(":n", AttributeValue.builder().s("Bob").build()) + .build()) + .build(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("profile.city") + .value(":c") + .putExpressionValue(":c", AttributeValue.builder().s("Paris").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("profile.name") + .value(":n") + .putExpressionValue(":n", AttributeValue.builder().s("Bob").build()) + .build(), + SetAction.builder() + .path("profile.city") + .value(":c") + .putExpressionValue(":c", AttributeValue.builder().s("Paris").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — all sources null → returns null + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_allSourcesNull_returnsNull() { + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + assertThat(result).isNull(); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — request only (no extension, no POJO) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_requestOnlyNoOtherSources_returnsRequestAsIs() { + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("v").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("attr") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("v").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — DELETE action from extension vs SET from request on same attribute + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_extensionDeleteVsRequestSet_keepsRequestSetOnly() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(DeleteAction.builder() + .path("tags") + .value(":toRemove") + .putExpressionValue(":toRemove", + AttributeValue.builder() + .ss("old-tag") + .build()) + .build()) + .build(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("tags") + .value(":newTags") + .putExpressionValue(":newTags", + AttributeValue.builder() + .ss("new-tag") + .build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("tags") + .value(":newTags") + .putExpressionValue(":newTags", AttributeValue.builder().ss("new-tag").build()) + .build()); + assertThat(result.deleteActions()).isEmpty(); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — extension ADD vs request SET on same attribute + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_extensionAddVsRequestSet_keepsRequestSetOnly() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(AddAction.builder() + .path("counter") + .value(":inc") + .putExpressionValue(":inc", AttributeValue.builder().n("1").build()) + .build()) + .build(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().n("100").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("counter") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().n("100").build()) + .build()); + assertThat(result.addActions()).isEmpty(); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE — POJO REMOVE vs extension SET (no request) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_pojoRemoveVsExtensionSet_keepsExtensionOnly() { + Map itemMap = new HashMap<>(); + itemMap.put("status", AttributeValue.builder().nul(true).build()); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("status") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("active").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("status") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("active").build()) + .build()); + assertThat(result.removeActions()).isEmpty(); + } + + /* + * LEGACY: concatenate all SETs in order: item → extension → request (6 actions). + * attr1 appears in all three (3 SETs); attr2 in ext+req (2); attr3 only from pojo. + */ + @Test + public void resolve_legacy_multiSourceOverlap_concatenatesAllSetActions() { + Map itemMap = new LinkedHashMap<>(); + itemMap.put("attr1", AttributeValue.builder().s("value1_pojo").build()); + itemMap.put("attr3", AttributeValue.builder().s("value3_pojo").build()); + + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr1") + .value(":e1") + .putExpressionValue(":e1", AttributeValue.builder().s("value1_ext").build()) + .build()) + .addAction(SetAction.builder() + .path("attr2") + .value(":e2") + .putExpressionValue(":e2", AttributeValue.builder().s("value2_ext").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr1") + .value(":r1") + .putExpressionValue(":r1", AttributeValue.builder().s("value1_req").build()) + .build()) + .addAction(SetAction.builder() + .path("attr2") + .value(":r2") + .putExpressionValue(":r2", AttributeValue.builder().s("value2_req").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("#AMZN_MAPPED_attr1") + .value(":AMZN_MAPPED_attr1") + .putExpressionName("#AMZN_MAPPED_attr1", "attr1") + .putExpressionValue(":AMZN_MAPPED_attr1", AttributeValue.builder().s("value1_pojo").build()) + .build(), + SetAction.builder() + .path("#AMZN_MAPPED_attr3") + .value(":AMZN_MAPPED_attr3") + .putExpressionName("#AMZN_MAPPED_attr3", "attr3") + .putExpressionValue(":AMZN_MAPPED_attr3", AttributeValue.builder().s("value3_pojo").build()) + .build(), + SetAction.builder() + .path("attr1") + .value(":e1") + .putExpressionValue(":e1", AttributeValue.builder().s("value1_ext").build()) + .build(), + SetAction.builder() + .path("attr2") + .value(":e2") + .putExpressionValue(":e2", AttributeValue.builder().s("value2_ext").build()) + .build(), + SetAction.builder() + .path("attr1") + .value(":r1") + .putExpressionValue(":r1", AttributeValue.builder().s("value1_req").build()) + .build(), + SetAction.builder() + .path("attr2") + .value(":r2") + .putExpressionValue(":r2", AttributeValue.builder().s("value2_req").build()) + .build()); + } + + /* + * PRIORITIZE_HIGHER_SOURCE: path overlap only; request beats extension beats item on overlapping paths. + * Same data as the LEGACY test above (3 actions). + * Winners: attr1 & attr2 → request; attr3 → item only (no overlap with request/extension paths). + */ + @Test + public void resolve_prioritizeHigherSource_multiSourceOverlap_oneSetActionPerTopLevelName() { + Map itemMap = new LinkedHashMap<>(); + itemMap.put("attr1", AttributeValue.builder().s("value1_pojo").build()); + itemMap.put("attr3", AttributeValue.builder().s("value3_pojo").build()); + + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr1") + .value(":e1") + .putExpressionValue(":e1", AttributeValue.builder().s("value1_ext").build()) + .build()) + .addAction(SetAction.builder() + .path("attr2") + .value(":e2") + .putExpressionValue(":e2", AttributeValue.builder().s("value2_ext").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr1") + .value(":r1") + .putExpressionValue(":r1", AttributeValue.builder().s("value1_req").build()) + .build()) + .addAction(SetAction.builder() + .path("attr2") + .value(":r2") + .putExpressionValue(":r2", AttributeValue.builder().s("value2_req").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("attr1") + .value(":r1") + .putExpressionValue(":r1", AttributeValue.builder().s("value1_req").build()) + .build(), + SetAction.builder() + .path("attr2") + .value(":r2") + .putExpressionValue(":r2", AttributeValue.builder().s("value2_req").build()) + .build(), + SetAction.builder() + .path("#AMZN_MAPPED_attr3") + .value(":AMZN_MAPPED_attr3") + .putExpressionName("#AMZN_MAPPED_attr3", "attr3") + .putExpressionValue(":AMZN_MAPPED_attr3", AttributeValue.builder().s("value3_pojo").build()) + .build()); + } + + // Path overlap: candidatePath.startsWith(higher + ".") and startsWith(higher + "[") + + @Test + public void resolve_prioritizeHigherSource_extensionNestedMapPathUnderRequestParentPath_dropsExtension() { + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("parent") + .value(":v") + .putExpressionValue(":v", + AttributeValue.builder().m(Collections.emptyMap()).build()) + .build()) + .build(); + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("parent.child") + .value(":v2") + .putExpressionValue(":v2", AttributeValue.builder().s("ext").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("parent") + .value(":v") + .putExpressionValue(":v", AttributeValue.builder().m(Collections.emptyMap()).build()) + .build()); + } + + @Test + public void resolve_prioritizeHigherSource_extensionListIndexUnderRequestListRoot_dropsExtension() { + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("items") + .value(":v") + .putExpressionValue(":v", + AttributeValue.builder().l(AttributeValue.builder().s("req").build()).build()) + .build()) + .build(); + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("items[0]") + .value(":v0") + .putExpressionValue(":v0", AttributeValue.builder().s("a").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("items") + .value(":v") + .putExpressionValue(":v", AttributeValue.builder().l(AttributeValue.builder().s("req").build()).build()) + .build()); + } +} \ 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..932600f10675 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtilsTest.java @@ -0,0 +1,330 @@ +/* + * 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 java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; +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; + +public class UpdateExpressionUtilsTest { + + private static final TableMetadata TABLE_METADATA = StaticTableMetadata.builder().build(); + + @Test + public void ifNotExists_mapsKeyAndValueToIfNotExistsExpression() { + String result = UpdateExpressionUtils.ifNotExists("key", "value"); + + assertThat(result).isEqualTo("if_not_exists(#AMZN_MAPPED_key, :AMZN_MAPPED_value)"); + } + + @Test + public void setActionsFor_emptyMap_returnsEmptyList() { + List result = UpdateExpressionUtils.setActionsFor(Collections.emptyMap(), TABLE_METADATA); + + assertThat(result).isEmpty(); + } + + @Test + public void setActionsFor_singleAttribute_createsSetAction() { + Map attributes = new HashMap<>(); + attributes.put("attr", AttributeValue.builder().s("attrValue").build()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); + + assertThat(result).isEqualTo(Collections.singletonList( + SetAction.builder() + .path("#AMZN_MAPPED_attr") + .value(":AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .putExpressionValue(":AMZN_MAPPED_attr", AttributeValue.builder().s("attrValue").build()) + .build())); + } + + @Test + public void setActionsFor_multipleAttributes_createsMultipleSetActions() { + Map attributes = new HashMap<>(); + attributes.put("attr", AttributeValue.builder().s("attrValue").build()); + attributes.put("attrTwo", AttributeValue.builder().n("2").build()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); + + assertThat(result).hasSize(2).containsExactlyInAnyOrder( + SetAction.builder() + .path("#AMZN_MAPPED_attr") + .value(":AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .putExpressionValue(":AMZN_MAPPED_attr", AttributeValue.builder().s("attrValue").build()) + .build(), + + SetAction.builder() + .path("#AMZN_MAPPED_attrTwo") + .value(":AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") + .putExpressionValue(":AMZN_MAPPED_attrTwo", AttributeValue.builder().n("2").build()) + .build()); + } + + @Test + public void setActionsFor_twoLevelDottedPath_producesSingleMappedSetAction() { + Map attributes = new HashMap<>(); + attributes.put("level1.level2", AttributeValue.builder().s("attrValue").build()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); + + assertThat(result).isEqualTo(Collections.singletonList( + SetAction.builder() + .path("#AMZN_MAPPED_level1_level2") + .value(":AMZN_MAPPED_level1_level2") + .putExpressionName("#AMZN_MAPPED_level1_level2", "level1.level2") + .putExpressionValue(":AMZN_MAPPED_level1_level2", AttributeValue.builder().s("attrValue").build()) + .build())); + } + + @Test + public void setActionsFor_threeLevelDottedPath_producesSingleMappedSetAction() { + Map attributes = new HashMap<>(); + attributes.put("level1.level2.level3", AttributeValue.builder().s("attrValue").build()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); + + assertThat(result).isEqualTo(Collections.singletonList( + SetAction.builder() + .path("#AMZN_MAPPED_level1_level2_level3") + .value(":AMZN_MAPPED_level1_level2_level3") + .putExpressionName("#AMZN_MAPPED_level1_level2_level3", "level1.level2.level3") + .putExpressionValue(":AMZN_MAPPED_level1_level2_level3", AttributeValue.builder().s("attrValue").build()) + .build())); + } + + @Test + public void setActionsFor_dashAndUnderscoreNames_producesDistinctMappedSetActions() { + Map attributes = new HashMap<>(); + attributes.put("attrWithDash", AttributeValue.builder().s("#value").build()); + attributes.put("attrWithUnderscore", AttributeValue.builder().s("_value").build()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); + + assertThat(result).hasSize(2).containsExactlyInAnyOrder( + SetAction.builder() + .path("#AMZN_MAPPED_attrWithDash") + .value(":AMZN_MAPPED_attrWithDash") + .putExpressionName("#AMZN_MAPPED_attrWithDash", "attrWithDash") + .putExpressionValue(":AMZN_MAPPED_attrWithDash", AttributeValue.builder().s("#value").build()) + .build(), + + SetAction.builder() + .path("#AMZN_MAPPED_attrWithUnderscore") + .value(":AMZN_MAPPED_attrWithUnderscore") + .putExpressionName("#AMZN_MAPPED_attrWithUnderscore", "attrWithUnderscore") + .putExpressionValue(":AMZN_MAPPED_attrWithUnderscore", AttributeValue.builder().s("_value").build()) + .build()); + } + + @Test + public void removeActionsFor_emptyMap_returnsEmptyList() { + List result = UpdateExpressionUtils.removeActionsFor(Collections.emptyMap()); + + assertThat(result).isEmpty(); + } + + @Test + public void removeActionsFor_singleAttribute_createsRemoveAction() { + Map attributes = new HashMap<>(); + attributes.put("attr", AttributeValue.builder().s("attrValue").build()); + + List result = UpdateExpressionUtils.removeActionsFor(attributes); + + assertThat(result).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .build())); + } + + @Test + public void removeActionsFor_multipleAttributes_createsMultipleRemoveActions() { + Map attributes = new HashMap<>(); + attributes.put("attr", AttributeValue.builder().nul(true).build()); + attributes.put("attrTwo", AttributeValue.builder().nul(true).build()); + + List result = UpdateExpressionUtils.removeActionsFor(attributes); + + assertThat(result).hasSize(2).containsExactlyInAnyOrder( + RemoveAction.builder() + .path("#AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .build(), + RemoveAction.builder() + .path("#AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") + .build()); + } + + @Test + public void removeActionsFor_twoLevelDottedPath_producesSingleMappedRemoveAction() { + Map attributes = new HashMap<>(); + attributes.put("level1.level2", AttributeValue.builder().s("attrValue").build()); + + List result = UpdateExpressionUtils.removeActionsFor(attributes); + + assertThat(result).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_level1_level2") + .putExpressionName("#AMZN_MAPPED_level1_level2", "level1.level2") + .build())); + } + + @Test + public void removeActionsFor_threeLevelDottedPath_producesSingleMappedRemoveAction() { + Map attributes = new HashMap<>(); + attributes.put("level1.level2.level3", AttributeValue.builder().s("attrValue").build()); + + List result = UpdateExpressionUtils.removeActionsFor(attributes); + + assertThat(result).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_level1_level2_level3") + .putExpressionName("#AMZN_MAPPED_level1_level2_level3", "level1.level2.level3") + .build())); + } + + @Test + public void removeActionsFor_dashAndUnderscoreNames_producesDistinctMappedRemoveActions() { + Map attributes = new HashMap<>(); + attributes.put("attrWithDash", AttributeValue.builder().s("#value").build()); + attributes.put("attrWithUnderscore", AttributeValue.builder().s("_value").build()); + + List result = UpdateExpressionUtils.removeActionsFor(attributes); + + assertThat(result).hasSize(2).containsExactlyInAnyOrder( + RemoveAction.builder() + .path("#AMZN_MAPPED_attrWithDash") + .putExpressionName("#AMZN_MAPPED_attrWithDash", "attrWithDash") + .build(), + + RemoveAction.builder() + .path("#AMZN_MAPPED_attrWithUnderscore") + .putExpressionName("#AMZN_MAPPED_attrWithUnderscore", "attrWithUnderscore") + .build()); + } + + @Test + public void attributesPresentInOtherExpressions_skipsNullExpressions() { + UpdateExpression expression = + UpdateExpression.builder().addAction( + SetAction.builder() + .path("customer.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("john").build()) + .build()) + .build(); + + Set result = UpdateExpressionUtils.attributesPresentInOtherExpressions(Arrays.asList(null, expression)); + + assertThat(result).containsExactly("customer"); + } + + @Test + public void attributesPresentInOtherExpressions_unionsTopLevelNamesFromEachExpression() { + UpdateExpression reqExpression = + UpdateExpression.builder() + .addAction( + SetAction.builder() + .path("reqAttr.name") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("john").build()) + .build()) + .build(); + + UpdateExpression extExpression = + UpdateExpression.builder() + .addAction(RemoveAction.builder().path("extAttr[0]").build()) + .build(); + + Set result = UpdateExpressionUtils.attributesPresentInOtherExpressions( + Arrays.asList(reqExpression, extExpression)); + + assertThat(result).containsExactlyInAnyOrder("reqAttr", "extAttr"); + } + + @Test + public void generateItemSetExpression_excludesNullAttributes() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + itemMap.put("attrToRemove", AttributeValue.builder().nul(true).build()); + + UpdateExpression result = UpdateExpressionUtils.generateItemSetExpression(itemMap, TABLE_METADATA); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("#AMZN_MAPPED_attr") + .value(":AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") + .putExpressionValue(":AMZN_MAPPED_attr", AttributeValue.builder().s("attrValue").build()) + .build()); + } + + @Test + public void generateItemRemoveExpression_skipsNonNullValuesAndExcludedAttributes() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + itemMap.put("attrToRemove", AttributeValue.builder().nul(true).build()); + itemMap.put("excludedAttr", AttributeValue.builder().nul(true).build()); + + UpdateExpression result = UpdateExpressionUtils.generateItemRemoveExpression(itemMap, singleton( + "excludedAttr")); + + assertThat(result.removeActions()).containsExactly( + RemoveAction.builder() + .path("#AMZN_MAPPED_attrToRemove") + .putExpressionName("#AMZN_MAPPED_attrToRemove", "attrToRemove") + .build()); + } + + @Test + public void generateItemSetExpression_whenOnlyNullAttributes_returnsNoSetActions() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().nul(true).build()); + + UpdateExpression result = UpdateExpressionUtils.generateItemSetExpression(itemMap, TABLE_METADATA); + + assertThat(result.setActions()).isEmpty(); + } + + @Test + public void generateItemRemoveExpression_whenOnlyNonNullAttributes_returnsNoRemoveActions() { + Map itemMap = new HashMap<>(); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + + UpdateExpression result = UpdateExpressionUtils.generateItemRemoveExpression(itemMap, Collections.emptySet()); + + assertThat(result.removeActions()).isEmpty(); + } +} + diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequestTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequestTest.java index 855d426fa8c7..63721714bb91 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequestTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequestTest.java @@ -22,6 +22,8 @@ import static org.hamcrest.Matchers.nullValue; import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem.createUniqueFakeItem; import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.LEGACY; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE; import org.junit.Test; import org.junit.runner.RunWith; @@ -34,7 +36,7 @@ public class TransactUpdateItemEnhancedRequestTest { @Test - public void builder_minimal() { + public void emptyBuilder_optionalFieldsAreNull_mergeStrategyIsLegacy() { TransactUpdateItemEnhancedRequest builtObject = TransactUpdateItemEnhancedRequest.builder(FakeItem.class).build(); @@ -42,6 +44,7 @@ public void builder_minimal() { assertThat(builtObject.ignoreNulls(), is(nullValue())); assertThat(builtObject.conditionExpression(), is(nullValue())); assertThat(builtObject.returnValuesOnConditionCheckFailure(), is(nullValue())); + assertThat(builtObject.updateExpressionMergeStrategy(), is(LEGACY)); } @Test @@ -284,4 +287,14 @@ public void builder_returnValuesOnConditionCheckFailureNewValue_stringGetter() { .build(); assertThat(builtObject.returnValuesOnConditionCheckFailureAsString(), is(returnValues)); } + + @Test + public void toBuilder_preservesPrioritizeHigherSourceMergeStrategy() { + TransactUpdateItemEnhancedRequest request = + TransactUpdateItemEnhancedRequest.builder(FakeItem.class) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build(); + + assertThat(request.toBuilder().build().updateExpressionMergeStrategy(), is(PRIORITIZE_HIGHER_SOURCE)); + } } \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequestTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequestTest.java index 539546a4fab3..7a6a3b7a4849 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequestTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequestTest.java @@ -22,6 +22,8 @@ import static org.hamcrest.Matchers.nullValue; import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem.createUniqueFakeItem; import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.LEGACY; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE; import org.junit.Test; import org.junit.runner.RunWith; @@ -36,7 +38,7 @@ public class UpdateItemEnhancedRequestTest { @Test - public void builder_minimal() { + public void emptyBuilder_optionalFieldsAreNull_mergeStrategyIsLegacy() { UpdateItemEnhancedRequest builtObject = UpdateItemEnhancedRequest.builder(FakeItem.class).build(); assertThat(builtObject.item(), is(nullValue())); @@ -47,6 +49,7 @@ public void builder_minimal() { assertThat(builtObject.returnItemCollectionMetrics(), is(nullValue())); assertThat(builtObject.returnItemCollectionMetricsAsString(), is(nullValue())); assertThat(builtObject.returnValuesOnConditionCheckFailure(), is(nullValue())); + assertThat(builtObject.updateExpressionMergeStrategy(), is(LEGACY)); } @Test @@ -304,4 +307,14 @@ public void hashCode_returnValuesOnConditionCheckFailure() { assertThat(containsItem.hashCode(), not(equalTo(emptyRequest.hashCode()))); } + + @Test + public void toBuilder_preservesPrioritizeHigherSourceMergeStrategy() { + UpdateItemEnhancedRequest request = + UpdateItemEnhancedRequest.builder(FakeItem.class) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build(); + + assertThat(request.toBuilder().build().updateExpressionMergeStrategy(), is(PRIORITIZE_HIGHER_SOURCE)); + } }