From dabb9129c943666ed7d78d26b2d4f4549596a878 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Mon, 9 Mar 2026 02:39:33 +0200 Subject: [PATCH 1/9] Support update expressions in single request update --- ...-AmazonDynamoDBEnhancedClient-d40ed28.json | 6 + .../operations/UpdateItemOperation.java | 56 +- .../update/UpdateExpressionResolver.java | 156 +++++ .../update/UpdateExpressionUtils.java | 29 +- .../TransactUpdateItemEnhancedRequest.java | 39 ++ .../model/UpdateItemEnhancedRequest.java | 38 ++ .../functionaltests/UpdateExpressionTest.java | 543 +++++++++++++++++- .../models/RecordForUpdateExpressions.java | 20 + .../update/UpdateExpressionResolverTest.java | 371 ++++++++++++ .../update/UpdateExpressionUtilsTest.java | 239 ++++++++ 10 files changed, 1424 insertions(+), 73 deletions(-) create mode 100644 .changes/next-release/feature-AmazonDynamoDBEnhancedClient-d40ed28.json create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolver.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolverTest.java create mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtilsTest.java 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..09537330a666 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,6 +34,7 @@ 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.UpdateItemEnhancedRequest; @@ -132,7 +131,7 @@ public UpdateItemRequest generateRequest(TableSchema tableSchema, Map keyAttributes = filterMap(itemMap, entry -> primaryKeys.contains(entry.getKey())); Map nonKeyAttributes = filterMap(itemMap, entry -> !primaryKeys.contains(entry.getKey())); - Expression updateExpression = generateUpdateExpressionIfExist(tableMetadata, transformation, nonKeyAttributes); + Expression updateExpression = generateUpdateExpressionIfExist(tableMetadata, transformation, nonKeyAttributes, request); Expression conditionExpression = generateConditionExpressionIfExist(transformation, request); Map expressionNames = coalesceExpressionNames(updateExpression, conditionExpression); @@ -271,27 +270,38 @@ 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. + * Merges UpdateExpressions from three sources in priority order: POJO attributes (lowest), + * extensions (medium), request (highest). Higher priority sources override conflicting actions. + * + *

Null POJO attributes normally generate REMOVE actions, but are skipped if the same + * attribute is referenced in extension/request expressions to avoid DynamoDB conflicts. + * + * @param tableMetadata metadata about the table structure + * @param transformation write modification from extensions containing UpdateExpression + * @param attributes non-key attributes from the POJO item + * @param request the update request containing optional explicit UpdateExpression + * @return merged Expression containing the final update expression, or null if no updates needed */ - 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)); + + UpdateExpressionResolver updateExpressionResolver = + UpdateExpressionResolver.builder() + .tableMetadata(tableMetadata) + .nonKeyAttributes(attributes) + .requestExpression(requestUpdateExpression) + .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/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..55625a615a56 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolver.java @@ -0,0 +1,156 @@ +/* + * 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.EnhancedClientUtils.isNullAttributeValue; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.removeActionsFor; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.setActionsFor; +import static software.amazon.awssdk.utils.CollectionUtils.filterMap; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +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.update.UpdateExpression; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Resolves and merges UpdateExpressions from multiple sources (item attributes, extensions, requests) with priority-based + * conflict resolution and smart filtering to prevent attribute conflicts. + */ +@SdkInternalApi +public final class UpdateExpressionResolver { + + private final TableMetadata tableMetadata; + private final Map nonKeyAttributes; + private final UpdateExpression extensionExpression; + private final UpdateExpression requestExpression; + + private UpdateExpressionResolver(Builder builder) { + this.tableMetadata = builder.tableMetadata; + this.nonKeyAttributes = builder.nonKeyAttributes; + this.extensionExpression = builder.extensionExpression; + this.requestExpression = builder.requestExpression; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Merges UpdateExpressions from three sources with priority: item attributes (lowest), extension expressions (medium), + * request expressions (highest). + * + *

Steps: Identify attributes used by extensions/requests to prevent REMOVE conflicts → + * create item SET/REMOVE actions → merge extensions (override item) → merge request (override all). + * + *

Backward compatibility: Without request expressions, behavior is identical to previous versions. + *

Exceptions: DynamoDbException may be thrown when the same attribute is updated by multiple sources. + * + * @return merged UpdateExpression, or empty if no updates needed + */ + 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)); + } + + return Stream.of(itemExpression, extensionExpression, requestExpression) + .filter(Objects::nonNull) + .reduce(UpdateExpression::mergeExpressions) + .orElse(null); + } + + private static Set attributesPresentInOtherExpressions(Collection updateExpressions) { + return updateExpressions.stream() + .filter(Objects::nonNull) + .map(UpdateExpressionConverter::findAttributeNames) + .flatMap(List::stream) + .collect(Collectors.toSet()); + } + + public static UpdateExpression generateItemSetExpression(Map itemMap, + TableMetadata tableMetadata) { + + Map setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue())); + return UpdateExpression.builder() + .actions(setActionsFor(setAttributes, tableMetadata)) + .build(); + } + + public 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(); + } + + public static final class Builder { + + private TableMetadata tableMetadata; + private Map nonKeyAttributes; + private UpdateExpression extensionExpression; + private UpdateExpression requestExpression; + + 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 UpdateExpressionResolver build() { + return new UpdateExpressionResolver(this); + } + + } +} \ No newline at end of file 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..3d12095160d6 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java @@ -15,11 +15,9 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.update; -import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; -import static software.amazon.awssdk.utils.CollectionUtils.filterMap; import java.util.Arrays; import java.util.Collections; @@ -35,7 +33,6 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; import software.amazon.awssdk.enhanced.dynamodb.update.RemoveAction; import software.amazon.awssdk.enhanced.dynamodb.update.SetAction; -import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @SdkInternalApi @@ -53,32 +50,10 @@ public static String ifNotExists(String key, String initValue) { return "if_not_exists(" + keyRef(key) + ", " + valueRef(initValue) + ")"; } - /** - * Generates an UpdateExpression representing a POJO, with only SET and REMOVE actions. - */ - public static UpdateExpression operationExpression(Map itemMap, - TableMetadata tableMetadata, - List nonRemoveAttributes) { - - 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.mergeExpressions(setAttributeExpression, removeAttributeExpression); - } - /** * 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,7 +65,7 @@ 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())) 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..0593ce9600e5 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,7 @@ public class TransactUpdateItemEnhancedRequest { private final Boolean ignoreNulls; private final IgnoreNullsMode ignoreNullsMode; private final Expression conditionExpression; + private final UpdateExpression updateExpression; private final String returnValuesOnConditionCheckFailure; private TransactUpdateItemEnhancedRequest(Builder builder) { @@ -49,6 +51,7 @@ private TransactUpdateItemEnhancedRequest(Builder builder) { this.ignoreNulls = builder.ignoreNulls; this.ignoreNullsMode = builder.ignoreNullsMode; this.conditionExpression = builder.conditionExpression; + this.updateExpression = builder.updateExpression; this.returnValuesOnConditionCheckFailure = builder.returnValuesOnConditionCheckFailure; } @@ -104,6 +107,13 @@ 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 what values to return if the condition check fails. *

@@ -152,6 +162,9 @@ public boolean equals(Object o) { if (!Objects.equals(conditionExpression, that.conditionExpression)) { return false; } + if (!Objects.equals(updateExpression, that.updateExpression)) { + return false; + } return Objects.equals(returnValuesOnConditionCheckFailure, that.returnValuesOnConditionCheckFailure); } @@ -160,6 +173,7 @@ 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(returnValuesOnConditionCheckFailure); return result; } @@ -175,6 +189,7 @@ public static final class Builder { private Boolean ignoreNulls; private IgnoreNullsMode ignoreNullsMode; private Expression conditionExpression; + private UpdateExpression updateExpression; private String returnValuesOnConditionCheckFailure; private Builder() { @@ -227,6 +242,30 @@ public Builder item(T item) { return this; } + /** + * Specifies custom update operations using DynamoDB's native update expression syntax. + *

+ * Precedence: When performing an update, the final set of attribute modifications is determined as follows: + *

    + *
  1. Request-level UpdateExpression (set via this method) has the highest priority and overrides any + * conflicting updates from extensions or POJO item attributes.
  2. + *
  3. Extension-provided UpdateExpression (if present) has medium priority and overrides conflicting updates + * from POJO item attributes.
  4. + *
  5. POJO item attributes have the lowest priority; any conflicts with extension or request expressions are + * overridden.
  6. + *
+ * If the same attribute is updated by multiple sources, only the action from the highest-priority source is applied. + *

+ * This method does not affect existing behavior if not used. + * + * @param updateExpression the update operations to perform + * @return a builder of this type + */ + public Builder updateExpression(UpdateExpression updateExpression) { + this.updateExpression = updateExpression; + 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/UpdateItemEnhancedRequest.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateItemEnhancedRequest.java index f7e714c7a690..af68d449df30 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,7 @@ public final class UpdateItemEnhancedRequest { private final Boolean ignoreNulls; private final IgnoreNullsMode ignoreNullsMode; private final Expression conditionExpression; + private final UpdateExpression updateExpression; private final String returnValues; private final String returnConsumedCapacity; private final String returnItemCollectionMetrics; @@ -59,6 +61,7 @@ private UpdateItemEnhancedRequest(Builder builder) { this.item = builder.item; this.ignoreNulls = builder.ignoreNulls; this.conditionExpression = builder.conditionExpression; + this.updateExpression = builder.updateExpression; this.ignoreNullsMode = builder.ignoreNullsMode; this.returnValues = builder.returnValues; this.returnConsumedCapacity = builder.returnConsumedCapacity; @@ -85,6 +88,7 @@ public Builder toBuilder() { .ignoreNulls(ignoreNulls) .ignoreNullsMode(ignoreNullsMode) .conditionExpression(conditionExpression) + .updateExpression(updateExpression) .returnValues(returnValues) .returnConsumedCapacity(returnConsumedCapacity) .returnItemCollectionMetrics(returnItemCollectionMetrics) @@ -121,6 +125,13 @@ 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; + } + /** * Whether to return the values of the item before this request. * @@ -210,6 +221,7 @@ 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(returnValues, that.returnValues) && Objects.equals(returnConsumedCapacity, that.returnConsumedCapacity) && Objects.equals(returnItemCollectionMetrics, that.returnItemCollectionMetrics) @@ -221,6 +233,7 @@ 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 + (returnValues != null ? returnValues.hashCode() : 0); result = 31 * result + (returnConsumedCapacity != null ? returnConsumedCapacity.hashCode() : 0); result = 31 * result + (returnItemCollectionMetrics != null ? returnItemCollectionMetrics.hashCode() : 0); @@ -239,6 +252,7 @@ public static final class Builder { private Boolean ignoreNulls; private IgnoreNullsMode ignoreNullsMode; private Expression conditionExpression; + private UpdateExpression updateExpression; private String returnValues; private String returnConsumedCapacity; private String returnItemCollectionMetrics; @@ -313,6 +327,30 @@ public Builder item(T item) { return this; } + /** + * Specifies custom update operations using DynamoDB's native update expression syntax. + *

+ * Precedence: When performing an update, the final set of attribute modifications is determined as follows: + *

    + *
  1. Request-level UpdateExpression (set via this method) has the highest priority and overrides any + * conflicting updates from extensions or POJO item attributes.
  2. + *
  3. Extension-provided UpdateExpression (if present) has medium priority and overrides conflicting updates + * from POJO item attributes.
  4. + *
  5. POJO item attributes have the lowest priority; any conflicts with extension or request expressions are + * overridden.
  6. + *
+ * If the same attribute is updated by multiple sources, only the action from the highest-priority source is applied. + *

+ * This method does not affect existing behavior if not used. + * + * @param updateExpression the update operations to perform + * @return a builder of this type + */ + public Builder updateExpression(UpdateExpression updateExpression) { + this.updateExpression = updateExpression; + 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..a5f1bbe5ff81 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,12 @@ 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.StaticAttributeTags.primaryPartitionKey; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -21,8 +25,13 @@ 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.model.ReadBatch; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactGetItemsEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; +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; @@ -30,6 +39,8 @@ 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,6 +50,7 @@ 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 String TABLE_NAME = "table-name"; private static final TableSchema TABLE_SCHEMA = TableSchema.fromClass(RecordForUpdateExpressions.class); private DynamoDbTable mappedTable; @@ -48,19 +60,85 @@ 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))); + } + } + + @Test + public void updateExpressionInRequest_atomicIncrementWithIfNotExists_shouldUpdateSuccessfully() { + 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 updateExpressionInRequest_atomicIncrementExistingValue_shouldUpdateSuccessfully() { + 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); } @Test public void attribute_notInPojo_notFilteredInExtension_ignoresNulls_updatesNormally() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.updateItem(r -> r.item(record).ignoreNulls(true)); @@ -83,7 +161,7 @@ public void attribute_notInPojo_notFilteredInExtension_ignoresNulls_updatesNorma @Test public void attribute_notInPojo_notFilteredInExtension_defaultSetsNull_updatesNormally() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.updateItem(r -> r.item(record)); @@ -95,8 +173,7 @@ public void attribute_notInPojo_notFilteredInExtension_defaultSetsNull_updatesNo @Test public void attribute_notInPojo_filteredInExtension_ignoresNulls_updatesNormally() { initClientWithExtensions(new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); - record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setStringAttribute("init"); @@ -108,8 +185,7 @@ public void attribute_notInPojo_filteredInExtension_ignoresNulls_updatesNormally @Test public void attribute_notInPojo_filteredInExtension_defaultSetsNull_updatesNormally() { initClientWithExtensions(new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); - record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setStringAttribute("init"); @@ -125,7 +201,7 @@ public void attribute_notInPojo_filteredInExtension_defaultSetsNull_updatesNorma @Test public void attribute_inPojo_notFilteredInExtension_ignoresNulls_ddbError() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); record.setExtensionNumberAttribute(100L); verifyDDBError(record, true); @@ -134,7 +210,7 @@ public void attribute_inPojo_notFilteredInExtension_ignoresNulls_ddbError() { @Test public void attribute_inPojo_notFilteredInExtension_defaultSetsNull_ddbError() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); record.setExtensionNumberAttribute(100L); verifyDDBError(record, false); @@ -148,8 +224,7 @@ public void attribute_inPojo_notFilteredInExtension_defaultSetsNull_ddbError() { @Test public void attribute_inPojo_filteredInExtension_ignoresNulls_updatesNormally() { initClientWithExtensions(new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); - record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setStringAttribute("init"); @@ -162,8 +237,7 @@ public void attribute_inPojo_filteredInExtension_ignoresNulls_updatesNormally() @Test public void attribute_inPojo_filteredInExtension_defaultSetsNull_updatesNormally() { initClientWithExtensions(new ItemFilteringUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); - record.setExtensionSetAttribute(SET_ATTRIBUTE_INIT_VALUE); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setStringAttribute("init"); @@ -176,12 +250,11 @@ public void attribute_inPojo_filteredInExtension_defaultSetsNull_updatesNormally @Test public void chainedExtensions_noDuplicates_ignoresNulls_updatesNormally() { 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)); @@ -194,25 +267,25 @@ public void chainedExtensions_noDuplicates_ignoresNulls_updatesNormally() { @Test public void chainedExtensions_duplicateAttributes_sameValue_sameValueRef_ddbError() { 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); + verifyDDBError(createFullRecord(), false); } @Test public void chainedExtensions_duplicateAttributes_differentValue_differentValueRef_ddbError() { 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(); + RecordForUpdateExpressions record = createFullRecord(); assertThatThrownBy(() ->mappedTable.updateItem(r -> r.item(record))) .isInstanceOf(IllegalArgumentException.class) @@ -223,7 +296,7 @@ public void chainedExtensions_duplicateAttributes_differentValue_sameValueRef_op @Test public void chainedExtensions_duplicateAttributes_invalidValueRef_operationMergeError() { initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemPreservingUpdateExtension(10L, "illegal")); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); assertThatThrownBy(() ->mappedTable.updateItem(r -> r.item(record))) .isInstanceOf(DynamoDbException.class) @@ -231,6 +304,387 @@ public void chainedExtensions_duplicateAttributes_invalidValueRef_operationMerge .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. + */ + @Test + public void updateExpressionInRequest_withoutIgnoreNulls_shouldUpdateSuccessfully() { + initClientWithExtensions(); + RecordForUpdateExpressions initialRecord = createFullRecord(); + putInitialItemAndVerify(initialRecord); + + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + mappedTable.updateItem(r -> r.item(keyRecord) + .updateExpression(expressionWithSetListElement(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 updateExpressionInRequest_withIgnoreNulls_shouldUpdateSuccessfully() { + initClientWithExtensions(); + RecordForUpdateExpressions initialRecord = createFullRecord(); + putInitialItemAndVerify(initialRecord); + + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + mappedTable.updateItem(r -> r.item(keyRecord) + .ignoreNulls(true) + .updateExpression(expressionWithSetListElement(1, "attr3"))); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + List requestAttributeList = persistedRecord.getRequestAttributeList(); + assertThat(requestAttributeList).hasSize(2).containsExactly("attr1", "attr3"); + } + + /** + * Tests DynamoDbException is thrown when same attribute is referenced both in the POJO item and in an explicit + * UpdateExpression provided on the request + */ + @Test + public void updateExpressionInRequest_whenAttributeAlsoInPojo_shouldThrowConflictError() { + initClientWithExtensions(); + RecordForUpdateExpressions initialRecord = createFullRecord(); + putInitialItemAndVerify(initialRecord); + + RecordForUpdateExpressions updateRecord = createKeyOnlyRecord(); + updateRecord.setRequestAttributeList(Collections.singletonList("attr1")); + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(updateRecord) + .updateExpression(expressionWithSetListElement(1, "attr3")))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths overlap"); + } + + /** + * Tests DynamoDbException is thrown when same attribute is referenced both in an extension's UpdateExpression and in an + * explicit UpdateExpression provided on the request. + */ + @Test + public void updateExpressionInRequest_whenAttributeAlsoInExtension_shouldThrowDynamoDbError() { + initClientWithExtensions(new ItemPreservingUpdateExtension()); + RecordForUpdateExpressions recordForUpdateExpressions = createKeyOnlyRecord(); + + // Create an UpdateExpression that conflicts with the extension's UpdateExpression + // Extension modifies extensionNumberAttribute, so we create a request expression that also modifies it + UpdateExpression conflictingExpression = UpdateExpression.builder() + .addAction(SetAction.builder() + .path("extensionNumberAttribute") + .value(":conflictValue") + .putExpressionValue(":conflictValue", + AttributeValue.builder().n("99").build()) + .build()) + .build(); + + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(recordForUpdateExpressions) + .updateExpression(conflictingExpression))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths") + .hasMessageContaining(NUMBER_ATTRIBUTE_REF); + } + + /** + * Tests backward compatibility: POJO-only updates should work unchanged. UpdateExpression functionality is opt-in - without + * providing an UpdateExpression on the request, behavior is identical ad before. + */ + @Test + public void backwardCompatibility_pojoOnlyUpdates() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createSimpleRecord(); + + // This should work exactly as before - just POJO updates, no extensions or request expressions + mappedTable.putItem(record); + record.setExtensionNumberAttribute(100L); + mappedTable.updateItem(r -> r.item(record)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(100L); + } + + /** + * Tests backward compatibility: Extension-only updates should work unchanged. UpdateExpression functionality is opt-in - + * without providing an UpdateExpression on the request, behavior is identical as before + */ + @Test + public void backwardCompatibility_extensionOnlyUpdates() { + initClientWithExtensions(new ItemPreservingUpdateExtension()); + RecordForUpdateExpressions record = createSimpleRecord(); + + // This should work exactly as before - extension updates attribute not in POJO + mappedTable.putItem(record); + mappedTable.updateItem(r -> r.item(record)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(5L); + } + + /** + * Tests scan() operation Verifies that scan operations work correctly after update expressions are applied. + */ + @Test + public void scanOperation_afterUpdateExpression() { + 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(expressionWithSetListElement(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"); + } + + /** + * Tests deleteItem() operation Verifies that items can be deleted after being updated with expressions. + */ + @Test + public void deleteItem_afterUpdateExpression() { + 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(expressionWithSetListElement(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(); + } + + /** + * Tests batchGetItem() operation Verifies that batch get operations work correctly after update expressions. + */ + @Test + public void batchGetItem_afterUpdateExpression() { + 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(expressionWithSetListElement(0, "batch1Updated"))); + RecordForUpdateExpressions keyRecord2 = createKeyOnlyRecord(); + keyRecord2.setId("batch2"); + mappedTable.updateItem(r -> r.item(keyRecord2) + .updateExpression(expressionWithSetListElement(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"); + } + + /** + * Tests batchWriteItem() operation Verifies that batch write operations work with items that have update expressions + * applied. + */ + @Test + public void batchWriteItem_withUpdateExpressionItems() { + 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(expressionWithSetListElement(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"); + } + + /** + * Tests transactGetItems() operation Verifies that transactional get operations work after update expressions. + */ + @Test + public void transactGetItems_afterUpdateExpression() { + 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(expressionWithSetListElement(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"); + } + + /** + * Tests transactWriteItems() operation Verifies that transactional write operations work correctly. + */ + @Test + public void transactWriteItems_withUpdateExpression() { + 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"); + } + + /** + * Tests StaticTableSchema with UpdateExpression extensions + */ + @Test + public void staticTableSchema_withUpdateExpressions() { + TableSchema staticSchema = TableSchema.builder(RecordForUpdateExpressions.class) + .newItemSupplier(RecordForUpdateExpressions::new) + .addAttribute(String.class, a -> a.name("id") + .getter(RecordForUpdateExpressions::getId) + .setter(RecordForUpdateExpressions::setId) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name( + "stringAttribute") + .getter(RecordForUpdateExpressions::getStringAttribute) + .setter(RecordForUpdateExpressions::setStringAttribute)) + .addAttribute(Long.class, a -> a.name( + "extensionNumberAttribute") + .getter(RecordForUpdateExpressions::getExtensionNumberAttribute) + .setter(RecordForUpdateExpressions::setExtensionNumberAttribute)) + .build(); + + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .extensions(new ItemPreservingUpdateExtension()) + .build(); + + String staticTableName = getConcreteTableName("static-table"); + DynamoDbTable staticTable = enhancedClient.table(staticTableName, staticSchema); + + try { + staticTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + + RecordForUpdateExpressions record = new RecordForUpdateExpressions(); + record.setId("static-test"); + record.setStringAttribute("init"); + + staticTable.updateItem(r -> r.item(record)); + + RecordForUpdateExpressions persistedRecord = staticTable.getItem(record); + assertThat(persistedRecord.getStringAttribute()).isEqualTo("init"); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(5L); + } finally { + getDynamoDbClient().deleteTable(r -> r.tableName(staticTableName)); + } + } + private void verifyDDBError(RecordForUpdateExpressions record, boolean ignoreNulls) { assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(record).ignoreNulls(ignoreNulls))) .isInstanceOf(DynamoDbException.class) @@ -246,13 +700,56 @@ 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); + return record; + } + + 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); + } + + private UpdateExpression expressionWithSetListElement(int index, String value) { + String listAttributeName = "requestAttributeList"; + String uniqueValueRef = ":val_" + value.replaceAll("[^a-zA-Z0-9]", "_"); + AttributeValue listElementValue = AttributeValue.builder().s(value).build(); + SetAction setListElement = SetAction.builder() + .path(keyRef(listAttributeName) + "[" + index + "]") + .value(uniqueValueRef) + .putExpressionValue(uniqueValueRef, listElementValue) + .putExpressionName(keyRef(listAttributeName), listAttributeName) + .build(); + return UpdateExpression.builder().addAction(setListElement).build(); + } + private static final class ItemPreservingUpdateExtension implements DynamoDbEnhancedClientExtension { private long incrementValue; private String valueRef; 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 index 2e2c89c8a265..6f4fd861d2f0 100644 --- 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 @@ -17,6 +17,7 @@ import static software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior.WRITE_IF_NOT_EXISTS; +import java.util.List; import java.util.Set; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; @@ -26,8 +27,10 @@ public class RecordForUpdateExpressions { private String id; private String stringAttribute1; + private List requestAttributeList; private Long extensionAttribute1; private Set extensionAttribute2; + private Long incrementedAttribute; @DynamoDbPartitionKey public String getId() { @@ -47,6 +50,14 @@ public void setStringAttribute(String stringAttribute1) { this.stringAttribute1 = stringAttribute1; } + public List getRequestAttributeList() { + return requestAttributeList; + } + + public void setRequestAttributeList(List stringRequestAttribute) { + this.requestAttributeList = stringRequestAttribute; + } + public Long getExtensionNumberAttribute() { return extensionAttribute1; } @@ -62,4 +73,13 @@ public Set getExtensionSetAttribute() { public void setExtensionSetAttribute(Set extensionAttribute2) { this.extensionAttribute2 = extensionAttribute2; } + + public Long getIncrementedAttribute() { + return incrementedAttribute; + } + + public RecordForUpdateExpressions setIncrementedAttribute(Long incrementedAttribute) { + this.incrementedAttribute = incrementedAttribute; + return this; + } } 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..39c4fa5f99a0 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionResolverTest.java @@ -0,0 +1,371 @@ +/* + * 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.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.update.AddAction; +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 UpdateExpressionResolverTest { + + private final TableMetadata mockTableMetadata = mock(TableMetadata.class); + + @Test + public void resolve_emptyInputs_returnsEmptyUpdateExpression() { + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .nonKeyAttributes(Collections.emptyMap()) + .build(); + + UpdateExpression result = resolver.resolve(); + + assertNull(result); + } + + @Test + public void resolve_nonNullAttributes_generatesSetActions() { + Map itemMap = new HashMap<>(); + itemMap.put("itemAttr1Name", AttributeValue.builder().s("itemAttr1Value").build()); + itemMap.put("itemAttr2Name", AttributeValue.builder().n("itemAttr2Value").build()); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .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_itemAttr1Name") + .value(":AMZN_MAPPED_itemAttr1Name") + .putExpressionName("#AMZN_MAPPED_itemAttr1Name", "itemAttr1Name") + .putExpressionValue(":AMZN_MAPPED_itemAttr1Name", AttributeValue.builder().s("itemAttr1Value").build()) + .build(), + + SetAction.builder() + .path("#AMZN_MAPPED_itemAttr2Name") + .value(":AMZN_MAPPED_itemAttr2Name") + .putExpressionName("#AMZN_MAPPED_itemAttr2Name", "itemAttr2Name") + .putExpressionValue(":AMZN_MAPPED_itemAttr2Name", AttributeValue.builder().n("itemAttr2Value").build()) + .build()); + } + + @Test + public void resolve_nullAttributes_generatesRemoveActions() { + Map itemMap = new HashMap<>(); + itemMap.put("itemAttr1Name", AttributeValue.builder().nul(true).build()); + itemMap.put("itemAttr2Name", AttributeValue.builder().nul(true).build()); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .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_itemAttr1Name") + .putExpressionName("#AMZN_MAPPED_itemAttr1Name", "itemAttr1Name") + .build(), + + RemoveAction.builder() + .path("#AMZN_MAPPED_itemAttr2Name") + .putExpressionName("#AMZN_MAPPED_itemAttr2Name", "itemAttr2Name") + .build()); + } + + @Test + public void resolve_mixedAttributes_generatesBothActions() { + Map itemMap = new HashMap<>(); + itemMap.put("setAttrName", AttributeValue.builder().s("setAttrValue").build()); + itemMap.put("removeAttrName", AttributeValue.builder().nul(true).build()); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .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_setAttrName") + .value(":AMZN_MAPPED_setAttrName") + .putExpressionName("#AMZN_MAPPED_setAttrName", "setAttrName") + .putExpressionValue(":AMZN_MAPPED_setAttrName", AttributeValue.builder().s("setAttrValue").build()) + .build())); + + assertThat(result.removeActions()).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_removeAttrName") + .putExpressionName("#AMZN_MAPPED_removeAttrName", "removeAttrName") + .build())); + } + + @Test + public void resolve_withItemAndExtensionExpression_mergesActions() { + Map itemMap = new HashMap<>(); + itemMap.put("itemAttrName", AttributeValue.builder().s("itemAttrValue").build()); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(AddAction.builder() + .path("extensionAttrName") + .value(":extensionAttrValue") + .putExpressionValue(":extensionAttrValue", + AttributeValue.builder().n("1").build()) + .build()) + .build(); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .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_itemAttrName") + .value(":AMZN_MAPPED_itemAttrName") + .putExpressionName("#AMZN_MAPPED_itemAttrName", "itemAttrName") + .putExpressionValue(":AMZN_MAPPED_itemAttrName", AttributeValue.builder().s("itemAttrValue").build()) + .build())); + + assertThat(result.addActions()).isEqualTo(Collections.singletonList( + AddAction.builder() + .path("extensionAttrName") + .value(":extensionAttrValue") + .putExpressionValue(":extensionAttrValue", AttributeValue.builder().n("1").build()) + .build())); + } + + @Test + public void resolve_withAllExpressionTypes_mergesInCorrectOrder() { + Map itemMap = new HashMap<>(); + itemMap.put("itemAttrName", AttributeValue.builder().s("itemAttrValue").build()); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(AddAction.builder() + .path("extensionAttrName") + .value(":extensionAttrName") + .putExpressionValue(":extensionAttrName", AttributeValue.builder().s( + "extensionAttrValue").build()) + .build()) + .build(); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("requestAttrName") + .value(":requestAttrName") + .putExpressionValue(":requestAttrName", AttributeValue.builder().s( + "requestAttrValue").build()) + .build()) + .build(); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .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_itemAttrName") + .value(":AMZN_MAPPED_itemAttrName") + .putExpressionName("#AMZN_MAPPED_itemAttrName", "itemAttrName") + .putExpressionValue(":AMZN_MAPPED_itemAttrName", AttributeValue.builder().s("itemAttrValue").build()) + .build(), + SetAction.builder() + .path("requestAttrName") + .value(":requestAttrName") + .putExpressionValue(":requestAttrName", AttributeValue.builder().s("requestAttrValue").build()) + .build()); + + assertThat(result.addActions()).isEqualTo(Collections.singletonList( + AddAction.builder() + .path("extensionAttrName") + .value(":extensionAttrName") + .putExpressionValue(":extensionAttrName", AttributeValue.builder().s("extensionAttrValue").build()) + .build())); + } + + @Test + public void resolve_attributeUsedInOtherExpression_filteredOutFromRemoveActions() { + Map itemMap = new HashMap<>(); + itemMap.put("itemAttr1Name", AttributeValue.builder().nul(true).build()); + itemMap.put("itemAttr2Name", AttributeValue.builder().nul(true).build()); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("itemAttr1Name") + .value(":itemAttr1Value") + .putExpressionName("#itemAttr1Name", "itemAttr1Name") + .putExpressionValue(":itemAttr1Value", AttributeValue.builder().s( + "itemAttr1Value_new").build()) + .build()) + .build(); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .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("itemAttr1Name") + .value(":itemAttr1Value") + .putExpressionName("#itemAttr1Name", "itemAttr1Name") + .putExpressionValue(":itemAttr1Value", AttributeValue.builder().s("itemAttr1Value_new").build()) + .build())); + + // only itemAttr2Name, itemAttr1Name filtered out (because was present in a set expression) + assertThat(result.removeActions()).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_itemAttr2Name") + .putExpressionName("#AMZN_MAPPED_itemAttr2Name", "itemAttr2Name") + .build())); + } + + @Test + public void generateItemSetExpression_andFiltersNullValues() { + Map itemMap = new HashMap<>(); + itemMap.put("validItemAttrName", AttributeValue.builder().s("validItemAttrValue").build()); + itemMap.put("nullItemAttrName", AttributeValue.builder().nul(true).build()); + + UpdateExpression result = UpdateExpressionResolver.generateItemSetExpression(itemMap, mockTableMetadata); + + assertThat(result).isNotNull(); + assertThat(result.removeActions()).isEmpty(); + assertThat(result.addActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.setActions()).isEqualTo(Collections.singletonList( + SetAction.builder() + .path("#AMZN_MAPPED_validItemAttrName") + .value(":AMZN_MAPPED_validItemAttrName") + .putExpressionName("#AMZN_MAPPED_validItemAttrName", "validItemAttrName") + .putExpressionValue(":AMZN_MAPPED_validItemAttrName", + AttributeValue.builder().s("validItemAttrValue").build()) + .build())); + } + + @Test + public void generateItemRemoveExpression_includesOnlyNullValues() { + Map itemMap = new HashMap<>(); + itemMap.put("validItemAttrName", AttributeValue.builder().s("validItemAttrValue").build()); + itemMap.put("nullItemAttrName", AttributeValue.builder().nul(true).build()); + + UpdateExpression result = UpdateExpressionResolver.generateItemRemoveExpression(itemMap, Collections.emptySet()); + + assertThat(result).isNotNull(); + assertThat(result.setActions()).isEmpty(); + assertThat(result.addActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.removeActions()).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_nullItemAttrName") + .putExpressionName("#AMZN_MAPPED_nullItemAttrName", "nullItemAttrName") + .build())); + } + + @Test + public void generateItemRemoveExpression_excludesNonRemovableAttributes() { + Map itemMap = new HashMap<>(); + itemMap.put("nullItemAttr1Name", AttributeValue.builder().nul(true).build()); + itemMap.put("nullItemAttr2Name", AttributeValue.builder().nul(true).build()); + + UpdateExpression result = UpdateExpressionResolver.generateItemRemoveExpression( + itemMap, Collections.singleton("nullItemAttr1Name")); + + assertThat(result).isNotNull(); + assertThat(result.setActions()).isEmpty(); + assertThat(result.addActions()).isEmpty(); + assertThat(result.deleteActions()).isEmpty(); + + assertThat(result.removeActions()).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_nullItemAttr2Name") + .putExpressionName("#AMZN_MAPPED_nullItemAttr2Name", "nullItemAttr2Name") + .build())); + } + + @Test + public void builder_allFields_buildsSuccessfully() { + Map itemMap = new HashMap<>(); + UpdateExpression extensionExpr = UpdateExpression.builder().build(); + UpdateExpression requestExpr = UpdateExpression.builder().build(); + + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpr) + .requestExpression(requestExpr) + .build(); + + assertThat(resolver).isNotNull(); + } +} \ 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..893aecc4a340 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtilsTest.java @@ -0,0 +1,239 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.internal.update; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.update.RemoveAction; +import software.amazon.awssdk.enhanced.dynamodb.update.SetAction; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +public class UpdateExpressionUtilsTest { + + private final TableMetadata mockTableMetadata = mock(TableMetadata.class); + + @Test + public void ifNotExists_createsCorrectExpression() { + 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(), mockTableMetadata); + + assertThat(result).isEmpty(); + } + + @Test + public void setActionsFor_singleAttribute_createsSetAction() { + Map attributes = new HashMap<>(); + attributes.put("attrName", AttributeValue.builder().s("attrValue").build()); + when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + + assertThat(result).isEqualTo(Collections.singletonList( + SetAction.builder() + .path("#AMZN_MAPPED_attrName") + .value(":AMZN_MAPPED_attrName") + .putExpressionName("#AMZN_MAPPED_attrName", "attrName") + .putExpressionValue(":AMZN_MAPPED_attrName", AttributeValue.builder().s("attrValue").build()) + .build())); + } + + @Test + public void setActionsFor_multipleAttributes_createsMultipleSetActions() { + Map attributes = new HashMap<>(); + attributes.put("attr1Name", AttributeValue.builder().s("attr1Value").build()); + attributes.put("attr2Name", AttributeValue.builder().n("attr2Value").build()); + when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + + assertThat(result).hasSize(2).containsExactlyInAnyOrder( + SetAction.builder() + .path("#AMZN_MAPPED_attr1Name") + .value(":AMZN_MAPPED_attr1Name") + .putExpressionName("#AMZN_MAPPED_attr1Name", "attr1Name") + .putExpressionValue(":AMZN_MAPPED_attr1Name", AttributeValue.builder().s("attr1Value").build()) + .build(), + + SetAction.builder() + .path("#AMZN_MAPPED_attr2Name") + .value(":AMZN_MAPPED_attr2Name") + .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") + .putExpressionValue(":AMZN_MAPPED_attr2Name", AttributeValue.builder().n("attr2Value").build()) + .build()); + } + + @Test + public void setActionsFor_nestedAttribute_handlesCorrectly() { + Map attributes = new HashMap<>(); + attributes.put("level1.level2", AttributeValue.builder().s("attrValue").build()); + when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + + 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_deeplyNestedAttribute_handlesCorrectly() { + Map attributes = new HashMap<>(); + attributes.put("level1.level2.level3", AttributeValue.builder().s("attrValue").build()); + when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + + 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_attributeWithSpecialCharacters_handlesCorrectly() { + Map attributes = new HashMap<>(); + attributes.put("attrWithDash", AttributeValue.builder().s("#value").build()); + attributes.put("attrWithUnderscore", AttributeValue.builder().s("_value").build()); + when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); + + List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + + 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("attrName", AttributeValue.builder().s("attrValue").build()); + + List result = UpdateExpressionUtils.removeActionsFor(attributes); + + assertThat(result).isEqualTo(Collections.singletonList( + RemoveAction.builder() + .path("#AMZN_MAPPED_attrName") + .putExpressionName("#AMZN_MAPPED_attrName", "attrName") + .build())); + } + + @Test + public void removeActionsFor_multipleAttributes_createsMultipleRemoveActions() { + Map attributes = new HashMap<>(); + attributes.put("attr1Name", AttributeValue.builder().nul(true).build()); + attributes.put("attr2Name", AttributeValue.builder().nul(true).build()); + + List result = UpdateExpressionUtils.removeActionsFor(attributes); + + assertThat(result).hasSize(2).containsExactlyInAnyOrder( + RemoveAction.builder() + .path("#AMZN_MAPPED_attr1Name") + .putExpressionName("#AMZN_MAPPED_attr1Name", "attr1Name") + .build(), + RemoveAction.builder() + .path("#AMZN_MAPPED_attr2Name") + .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") + .build()); + } + + @Test + public void removeActionsFor_nestedAttribute_handlesCorrectly() { + 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_deeplyNestedAttribute_handlesCorrectly() { + 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_attributeWithSpecialCharacters_handlesCorrectly() { + 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()); + } +} + From b0c9c0a0c597cfd2cc1ed55ba3f4487b5b4d63ef Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Thu, 12 Mar 2026 12:11:59 +0200 Subject: [PATCH 2/9] Preserve .updateExpression in toBuilder() --- .../dynamodb/model/TransactUpdateItemEnhancedRequest.java | 1 + 1 file changed, 1 insertion(+) 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 0593ce9600e5..ff99d50b4f9a 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 @@ -74,6 +74,7 @@ public Builder toBuilder() { .ignoreNulls(ignoreNulls) .ignoreNullsMode(ignoreNullsMode) .conditionExpression(conditionExpression) + .updateExpression(updateExpression) .returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure); } From 34616c47f6c37600720085557d3b43caecca32a6 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Thu, 12 Mar 2026 13:20:33 +0200 Subject: [PATCH 3/9] Addressed PR feedback --- .../dynamodb/internal/update/UpdateExpressionResolver.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 55625a615a56..0858dee3373f 100644 --- 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 @@ -97,7 +97,7 @@ private static Set attributesPresentInOtherExpressions(Collection itemMap, + private static UpdateExpression generateItemSetExpression(Map itemMap, TableMetadata tableMetadata) { Map setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue())); @@ -106,7 +106,7 @@ public static UpdateExpression generateItemSetExpression(Map itemMap, + private static UpdateExpression generateItemRemoveExpression(Map itemMap, Collection nonRemoveAttributes) { Map removeAttributes = filterMap(itemMap, e -> isNullAttributeValue(e.getValue()) && !nonRemoveAttributes.contains(e.getKey())); From 2e013a62658d8d00a68589a977e531c9f0895284 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Thu, 26 Mar 2026 10:58:30 +0200 Subject: [PATCH 4/9] Addressed PR feedback --- .../operations/UpdateItemOperation.java | 22 +- .../update/UpdateExpressionConverter.java | 15 +- .../update/UpdateExpressionResolver.java | 151 ++- .../TransactUpdateItemEnhancedRequest.java | 51 +- .../model/UpdateExpressionMergeStrategy.java | 53 + .../model/UpdateItemEnhancedRequest.java | 51 +- .../functionaltests/UpdateExpressionTest.java | 440 +++++++- .../UpdateItemOperationTransactTest.java | 42 +- .../update/UpdateExpressionResolverTest.java | 941 +++++++++++++++++- .../update/UpdateExpressionUtilsTest.java | 14 +- ...TransactUpdateItemEnhancedRequestTest.java | 15 +- .../model/UpdateItemEnhancedRequestTest.java | 17 +- 12 files changed, 1671 insertions(+), 141 deletions(-) create mode 100644 services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateExpressionMergeStrategy.java 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 09537330a666..1a87ca27c062 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 @@ -37,6 +37,7 @@ 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; @@ -270,17 +271,9 @@ public TransactWriteItem generateTransactWriteItem(TableSchema tableSchema, O } /** - * Merges UpdateExpressions from three sources in priority order: POJO attributes (lowest), - * extensions (medium), request (highest). Higher priority sources override conflicting actions. - * - *

Null POJO attributes normally generate REMOVE actions, but are skipped if the same - * attribute is referenced in extension/request expressions to avoid DynamoDB conflicts. - * - * @param tableMetadata metadata about the table structure - * @param transformation write modification from extensions containing UpdateExpression - * @param attributes non-key attributes from the POJO item - * @param request the update request containing optional explicit UpdateExpression - * @return merged Expression containing the final update expression, or null if no updates needed + * 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} (one winning source per top-level attribute name). */ private Expression generateUpdateExpressionIfExist( TableMetadata tableMetadata, @@ -292,11 +285,18 @@ private Expression generateUpdateExpressionIfExist( 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(); 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..a9a071131d5f 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 @@ -112,6 +112,18 @@ public static List findAttributeNames(UpdateExpression updateExpression) return attributeNames; } + /** + * Returns the top-level segment of a DynamoDB update expression document path: the substring before the first + * {@code .} (nested map attribute) or {@code [} (list index). For example, {@code attr}, {@code attr[0]}, and + * {@code attr.nested} all share the same top-level name {@code attr}, which is the DynamoDB attribute used for grouping and + * overlap rules. + * + * @param attributeName a path or name segment after any {@code #} expression-name substitution; must not be {@code null} + */ + static String removeNestingAndListReference(String attributeName) { + return attributeName.substring(0, getRemovalIndex(attributeName)); + } + private static List groupExpressions(UpdateExpression expression) { List groupExpressions = new ArrayList<>(); if (!expression.setActions().isEmpty()) { @@ -216,9 +228,6 @@ private static List listAttributeNamesFromTokens(UpdateExpression update .collect(Collectors.toList()); } - private static String removeNestingAndListReference(String attributeName) { - return attributeName.substring(0, getRemovalIndex(attributeName)); - } private static int getRemovalIndex(String attributeName) { for (int i = 0; i < attributeName.length(); i++) { 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 index 0858dee3373f..65d3b4a7f248 100644 --- 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 @@ -17,14 +17,18 @@ import static java.util.Objects.requireNonNull; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter.findAttributeNames; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter.removeNestingAndListReference; import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.removeActionsFor; import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.setActionsFor; import static software.amazon.awssdk.utils.CollectionUtils.filterMap; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -33,12 +37,17 @@ 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.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; /** - * Resolves and merges UpdateExpressions from multiple sources (item attributes, extensions, requests) with priority-based - * conflict resolution and smart filtering to prevent attribute conflicts. + * Merges update actions from POJO attributes, extensions, and request-level expressions into a single {@link UpdateExpression}. + * Merge behavior is controlled by {@link UpdateExpressionMergeStrategy}. + * + * @see UpdateExpressionMergeStrategy */ @SdkInternalApi public final class UpdateExpressionResolver { @@ -47,12 +56,14 @@ public final class UpdateExpressionResolver { 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() { @@ -60,16 +71,30 @@ public static Builder builder() { } /** - * Merges UpdateExpressions from three sources with priority: item attributes (lowest), extension expressions (medium), - * request expressions (highest). + * Merges update actions from POJO, extension, and request sources into one {@link UpdateExpression}. Previously, all sources + * were always concatenated and sent to DynamoDB; when two actions targeted overlapping document paths (for example, replacing + * an entire attribute and also updating a nested path under that same attribute), the service responded with a "Two document + * paths overlap" error. + *

+ * To avoid a breaking change, {@link UpdateExpressionMergeStrategy} was added: it defaults to + * {@link UpdateExpressionMergeStrategy#LEGACY}, preserving that original merge behavior. When set to + * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}, the resolver drops conflicting lower-priority actions per + * top-level attribute name so the request can succeed. * - *

Steps: Identify attributes used by extensions/requests to prevent REMOVE conflicts → - * create item SET/REMOVE actions → merge extensions (override item) → merge request (override all). + *

    + *
  • {@link UpdateExpressionMergeStrategy#LEGACY} (default) — concatenates all actions as-is; + * overlapping paths cause a DynamoDB runtime error. Null-attribute REMOVE actions are suppressed when the + * same attribute appears in an extension or request expression.
  • * - *

    Backward compatibility: Without request expressions, behavior is identical to previous versions. - *

    Exceptions: DynamoDbException may be thrown when the same attribute is updated by multiple sources. + *

  • {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE} — groups actions by top-level + * attribute name (path before first {@code .} or {@code [}). For each name, only the highest-priority + * source's actions are kept: request > extension > POJO. Different top-level names do not + * compete with each other: one attribute may contribute only request actions and another only extension actions, + * and both groups still appear in the merged expression.
  • + *
* - * @return merged UpdateExpression, or empty if no updates needed + * @return the merged expression, or {@code null} when no updates are needed + * @see UpdateExpressionMergeStrategy */ public UpdateExpression resolve() { UpdateExpression itemExpression = null; @@ -83,6 +108,10 @@ public UpdateExpression resolve() { generateItemRemoveExpression(nonKeyAttributes, attributesExcludedFromRemoval)); } + if (updateExpressionMergeStrategy == UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE) { + return mergeBySourcePriority(itemExpression, extensionExpression, requestExpression); + } + return Stream.of(itemExpression, extensionExpression, requestExpression) .filter(Objects::nonNull) .reduce(UpdateExpression::mergeExpressions) @@ -98,7 +127,7 @@ private static Set attributesPresentInOtherExpressions(Collection itemMap, - TableMetadata tableMetadata) { + TableMetadata tableMetadata) { Map setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue())); return UpdateExpression.builder() @@ -107,7 +136,7 @@ private static UpdateExpression generateItemSetExpression(Map itemMap, - Collection nonRemoveAttributes) { + Collection nonRemoveAttributes) { Map removeAttributes = filterMap(itemMap, e -> isNullAttributeValue(e.getValue()) && !nonRemoveAttributes.contains(e.getKey())); @@ -116,12 +145,100 @@ private static UpdateExpression generateItemRemoveExpression(Map requestOwned = new HashSet<>(findAttributeNames(requestExpression)); + Set extensionOwned = new HashSet<>(findAttributeNames(extensionExpression)); + + // Request wins over extension: extension only retains attribute names not already in the request expression. + extensionOwned.removeAll(requestOwned); + + Set itemOwned = new HashSet<>(findAttributeNames(itemExpression)); + // POJO-derived item expression is the lowest priority: drop attribute names claimed by request, then by extension. + itemOwned.removeAll(requestOwned); + itemOwned.removeAll(extensionOwned); + + return Stream.of( + filterByAttributes(requestExpression, requestOwned), + filterByAttributes(extensionExpression, extensionOwned), + filterByAttributes(itemExpression, itemOwned) + ).filter(Objects::nonNull) + .reduce(UpdateExpression::mergeExpressions) + .orElse(null); + } + + /** + * Returns a new {@link UpdateExpression} containing only actions whose resolved top-level attribute name is in + * {@code attributeNames}, or {@code null} if nothing matches. + */ + private static UpdateExpression filterByAttributes(UpdateExpression expression, Set attributeNames) { + if (expression == null || attributeNames.isEmpty()) { + return null; + } + List retainedActions = new ArrayList<>(); + + expression.setActions().stream() + .filter(action -> attributeNames.contains(baseAttributeName(action.path(), action.expressionNames()))) + .forEach(retainedActions::add); + + expression.removeActions().stream() + .filter(action -> attributeNames.contains(baseAttributeName(action.path(), action.expressionNames()))) + .forEach(retainedActions::add); + + expression.deleteActions().stream() + .filter(action -> attributeNames.contains(baseAttributeName(action.path(), action.expressionNames()))) + .forEach(retainedActions::add); + + expression.addActions().stream() + .filter(action -> attributeNames.contains(baseAttributeName(action.path(), action.expressionNames()))) + .forEach(retainedActions::add); + + return retainedActions.isEmpty() + ? null + : UpdateExpression.builder().actions(retainedActions).build(); + } + + /** + * Returns the root DynamoDB attribute name for an update action path, used when merging expressions (for example to + * decide which actions belong together or overlap). + *

+ * The path is resolved in two steps: + *

    + *
  1. Substitute {@link SetAction#expressionNames() expression name} placeholders: each entry's key is replaced by its + * value in {@code fullAttributePath} (in map iteration order), as with {@code ExpressionAttributeNames}.
  2. + *
  3. {@link UpdateExpressionConverter#removeNestingAndListReference(String)} then takes the segment before the first + * {@code .} (nested map) or {@code [} (list index)—the top-level attribute stored on the item.
  4. + *
+ *

+ * Examples (after any placeholder substitution): + *

    + *
  • {@code list[0]} → {@code list}
  • + *
  • {@code object.listAttr[0]} → {@code object}
  • + *
+ */ + private static String baseAttributeName(String fullAttributePath, Map expressionNames) { + String result = fullAttributePath; + + Map names = expressionNames != null ? expressionNames : Collections.emptyMap(); + for (Map.Entry entry : names.entrySet()) { + result = result.replace(entry.getKey(), entry.getValue()); + } + return removeNestingAndListReference(result); + } + public static final class Builder { private TableMetadata tableMetadata; - private Map nonKeyAttributes; + private Map nonKeyAttributes = Collections.emptyMap(); private UpdateExpression extensionExpression; private UpdateExpression requestExpression; + private UpdateExpressionMergeStrategy updateExpressionMergeStrategy = UpdateExpressionMergeStrategy.LEGACY; public Builder tableMetadata(TableMetadata tableMetadata) { this.tableMetadata = requireNonNull( @@ -148,9 +265,15 @@ public Builder requestExpression(UpdateExpression requestExpression) { return this; } + public Builder updateExpressionMergeStrategy(UpdateExpressionMergeStrategy updateExpressionMergeStrategy) { + this.updateExpressionMergeStrategy = updateExpressionMergeStrategy == null + ? UpdateExpressionMergeStrategy.LEGACY + : updateExpressionMergeStrategy; + return this; + } + public UpdateExpressionResolver build() { return new UpdateExpressionResolver(this); } - } -} \ No newline at end of file +} 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 ff99d50b4f9a..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 @@ -44,6 +44,7 @@ public class TransactUpdateItemEnhancedRequest { private final IgnoreNullsMode ignoreNullsMode; private final Expression conditionExpression; private final UpdateExpression updateExpression; + private final UpdateExpressionMergeStrategy updateExpressionMergeStrategy; private final String returnValuesOnConditionCheckFailure; private TransactUpdateItemEnhancedRequest(Builder builder) { @@ -52,6 +53,7 @@ private TransactUpdateItemEnhancedRequest(Builder builder) { this.ignoreNullsMode = builder.ignoreNullsMode; this.conditionExpression = builder.conditionExpression; this.updateExpression = builder.updateExpression; + this.updateExpressionMergeStrategy = builder.updateExpressionMergeStrategy; this.returnValuesOnConditionCheckFailure = builder.returnValuesOnConditionCheckFailure; } @@ -75,6 +77,7 @@ public Builder toBuilder() { .ignoreNullsMode(ignoreNullsMode) .conditionExpression(conditionExpression) .updateExpression(updateExpression) + .updateExpressionMergeStrategy(updateExpressionMergeStrategy) .returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure); } @@ -115,6 +118,16 @@ 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. *

@@ -166,6 +179,9 @@ public boolean equals(Object o) { if (!Objects.equals(updateExpression, that.updateExpression)) { return false; } + if (updateExpressionMergeStrategy != that.updateExpressionMergeStrategy) { + return false; + } return Objects.equals(returnValuesOnConditionCheckFailure, that.returnValuesOnConditionCheckFailure); } @@ -175,6 +191,7 @@ public int hashCode() { 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; } @@ -191,6 +208,7 @@ public static final class Builder { private IgnoreNullsMode ignoreNullsMode; private Expression conditionExpression; private UpdateExpression updateExpression; + private UpdateExpressionMergeStrategy updateExpressionMergeStrategy; private String returnValuesOnConditionCheckFailure; private Builder() { @@ -244,29 +262,36 @@ public Builder item(T item) { } /** - * Specifies custom update operations using DynamoDB's native update expression syntax. - *

- * Precedence: When performing an update, the final set of attribute modifications is determined as follows: - *

    - *
  1. Request-level UpdateExpression (set via this method) has the highest priority and overrides any - * conflicting updates from extensions or POJO item attributes.
  2. - *
  3. Extension-provided UpdateExpression (if present) has medium priority and overrides conflicting updates - * from POJO item attributes.
  4. - *
  5. POJO item attributes have the lowest priority; any conflicts with extension or request expressions are - * overridden.
  6. - *
- * If the same attribute is updated by multiple sources, only the action from the highest-priority source is applied. + * Specifies custom update actions using DynamoDB's native update expression syntax. This expression is combined with + * POJO-derived actions and extension-provided actions. *

- * This method does not affect existing behavior if not used. + * 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..69a92cff23d6 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/UpdateExpressionMergeStrategy.java @@ -0,0 +1,53 @@ +/* + * 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; + +/** + * Controls how update actions from three sources (POJO attributes, extensions, and request-level expressions) are combined before + * being sent to DynamoDB. + * + *

    + *
  • {@link #LEGACY} (default) — all actions are concatenated as-is. If two actions target overlapping + * document paths (for example, replacing an entire attribute and also updating a nested path under that same + * attribute), DynamoDB rejects the request with a "Two document paths overlap" error. The only automatic safety + * is that if a POJO attribute is {@code null}, its {@code REMOVE} action is suppressed when the same attribute + * name appears in an extension or request expression.
  • + * + *
  • {@link #PRIORITIZE_HIGHER_SOURCE} — actions are grouped by top-level attribute name (see below). + * For each name, only actions from the single highest-priority source that references that name are kept. + * Priority (highest to lowest): request > extension > POJO. Different top-level names do not compete with + * each other: one attribute may contribute only request actions and another only extension actions, and both + * groups still appear in the merged expression.
  • + *
+ * + *

Top-level name (for {@link #PRIORITIZE_HIGHER_SOURCE}): resolve expression-name placeholders, then take the + * part of the path before the first {@code .} or {@code [} (for example, {@code list[0]} → {@code list}, and + * {@code object.listAttr[0]} → {@code object}). If multiple sources update paths with the same top-level name, + * only the highest-priority source's actions for that attribute are kept. + * Precedence is: request > extension > POJO. + * + *

Default: {@link #LEGACY}. Not setting this flag preserves backward-compatible behavior. + * + * @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 af68d449df30..53d61464f92e 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 @@ -51,6 +51,7 @@ public final class UpdateItemEnhancedRequest { 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; @@ -62,6 +63,7 @@ private UpdateItemEnhancedRequest(Builder builder) { 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; @@ -89,6 +91,7 @@ public Builder toBuilder() { .ignoreNullsMode(ignoreNullsMode) .conditionExpression(conditionExpression) .updateExpression(updateExpression) + .updateExpressionMergeStrategy(updateExpressionMergeStrategy) .returnValues(returnValues) .returnConsumedCapacity(returnConsumedCapacity) .returnItemCollectionMetrics(returnItemCollectionMetrics) @@ -132,6 +135,16 @@ 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. * @@ -222,6 +235,7 @@ public boolean equals(Object o) { && 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) @@ -234,6 +248,7 @@ public int hashCode() { 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); @@ -253,6 +268,7 @@ public static final class Builder { private IgnoreNullsMode ignoreNullsMode; private Expression conditionExpression; private UpdateExpression updateExpression; + private UpdateExpressionMergeStrategy updateExpressionMergeStrategy; private String returnValues; private String returnConsumedCapacity; private String returnItemCollectionMetrics; @@ -328,29 +344,38 @@ public Builder item(T item) { } /** - * Specifies custom update operations using DynamoDB's native update expression syntax. - *

- * Precedence: When performing an update, the final set of attribute modifications is determined as follows: - *

    - *
  1. Request-level UpdateExpression (set via this method) has the highest priority and overrides any - * conflicting updates from extensions or POJO item attributes.
  2. - *
  3. Extension-provided UpdateExpression (if present) has medium priority and overrides conflicting updates - * from POJO item attributes.
  4. - *
  5. POJO item attributes have the lowest priority; any conflicts with extension or request expressions are - * overridden.
  6. - *
- * If the same attribute is updated by multiple sources, only the action from the highest-priority source is applied. + * Specifies custom update actions using DynamoDB's native update expression syntax. This expression is combined with + * POJO-derived actions and extension-provided actions. *

- * This method does not affect existing behavior if not used. + * Use {@link #updateExpressionMergeStrategy(UpdateExpressionMergeStrategy)} to control how conflicts between these + * sources are resolved ({@link UpdateExpressionMergeStrategy#LEGACY concatenation} vs + * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE top-level attribute winner}). * * @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} picks one winning source per top-level attribute name + * (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 a5f1bbe5ff81..3294a7b626d3 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 @@ -4,6 +4,9 @@ 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.StaticAttributeTags.primaryPartitionKey; +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; @@ -21,12 +24,15 @@ 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.WriteBatch; import software.amazon.awssdk.enhanced.dynamodb.update.AddAction; @@ -73,7 +79,7 @@ public void deleteTable() { } @Test - public void updateExpressionInRequest_atomicIncrementWithIfNotExists_shouldUpdateSuccessfully() { + public void updateItem_ifNotExistsPlusIncrement_whenCounterAbsent_persistsRequestedIncrement() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createKeyOnlyRecord(); @@ -104,7 +110,7 @@ public void updateExpressionInRequest_atomicIncrementWithIfNotExists_shouldUpdat } @Test - public void updateExpressionInRequest_atomicIncrementExistingValue_shouldUpdateSuccessfully() { + public void updateItem_ifNotExistsPlusIncrement_whenCounterPresent_persistsSumWithIncrement() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createKeyOnlyRecord(); @@ -310,7 +316,7 @@ public void chainedExtensions_duplicateAttributes_invalidValueRef_operationMerge * request, REMOVE actions are suppressed for attributes referenced in that UpdateExpression to avoid conflicts. */ @Test - public void updateExpressionInRequest_withoutIgnoreNulls_shouldUpdateSuccessfully() { + public void updateItem_requestExpressionSetListElement_ignoreNullsFalse_avoidsRemoveConflictsAndUpdatesList() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createFullRecord(); putInitialItemAndVerify(initialRecord); @@ -330,7 +336,7 @@ public void updateExpressionInRequest_withoutIgnoreNulls_shouldUpdateSuccessfull * operates independently of the ignoreNulls setting and updates the specified attributes. */ @Test - public void updateExpressionInRequest_withIgnoreNulls_shouldUpdateSuccessfully() { + public void updateItem_requestExpressionSetListElement_ignoreNullsTrue_updatesStoredList() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createFullRecord(); putInitialItemAndVerify(initialRecord); @@ -350,7 +356,7 @@ public void updateExpressionInRequest_withIgnoreNulls_shouldUpdateSuccessfully() * UpdateExpression provided on the request */ @Test - public void updateExpressionInRequest_whenAttributeAlsoInPojo_shouldThrowConflictError() { + public void updateItem_requestExpressionOverlapsPojoAttribute_dynamoDbRejectsOverlappingPaths() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createFullRecord(); putInitialItemAndVerify(initialRecord); @@ -363,17 +369,198 @@ public void updateExpressionInRequest_whenAttributeAlsoInPojo_shouldThrowConflic .hasMessageContaining("Two document paths overlap"); } - /** - * Tests DynamoDbException is thrown when same attribute is referenced both in an extension's UpdateExpression and in an - * explicit UpdateExpression provided on the request. - */ + // ------------------------------------------------------- + // Simple scalar attribute: POJO vs request — LEGACY + // ------------------------------------------------------- + + @Test + public void updateItem_whenLegacyMergeAndPojoAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setExtensionNumberAttribute(100L); + UpdateExpression requestExpression = expressionWithSetNumberAttribute("extensionNumberAttribute", 200L); + + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(record) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy(LEGACY))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths"); + } + + // ------------------------------------------------------- + // Simple scalar attribute: POJO vs request — PRIORITIZE_HIGHER_SOURCE + // ------------------------------------------------------- + + @Test + public void updateItem_whenPrioritizeHigherSourceAndPojoAndRequestSetSameScalar_thenRequestValueWins() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setExtensionNumberAttribute(100L); + mappedTable.updateItem(r -> r.item(record) + .updateExpression(expressionWithSetNumberAttribute("extensionNumberAttribute", 200L)) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(200L); + } + + // ------------------------------------------------------- + // List paths: three sources (POJO root, extension [0], request [1]) — PRIORITIZE_HIGHER_SOURCE + // ------------------------------------------------------- + + @Test + public void updateItem_whenPrioritizeHigherSourceAndPojoExtensionRequestTouchSameList_thenRequestMutationWins() { + initClientWithExtensions(new ListFirstElementUpdateExtension()); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setRequestAttributeList(Arrays.asList("pojo1", "pojo2")); + mappedTable.updateItem(r -> r.item(record) + .updateExpression(expressionWithSetListElement(1, "request1")) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getRequestAttributeList()).containsExactly("attr1", "request1"); + } + + // ------------------------------------------------------- + // Object parent/child: POJO root vs request nested path — LEGACY + // ------------------------------------------------------- + + @Test + public void updateItem_whenLegacyMergeAndPojoSetsObjectRootAndRequestSetsNestedField_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setObjectAttribute(nestedObject("pojoName", "pojoCity")); + + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(record) + .updateExpression( + expressionWithSetStringPath("objectAttribute.name", + "requestName")) + .updateExpressionMergeStrategy(LEGACY))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths"); + } + + // ------------------------------------------------------- + // Object parent/child: POJO root vs request nested path — PRIORITIZE_HIGHER_SOURCE + // ------------------------------------------------------- + + @Test + public void updateItem_whenPrioritizeHigherSourceAndPojoSetsObjectRootAndRequestSetsNestedField_thenNestedRequestValuePersists() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + record.setObjectAttribute(nestedObject("pojoName", "pojoCity")); + mappedTable.updateItem(r -> r.item(record) + .updateExpression(expressionWithSetStringPath("objectAttribute.name", "requestName")) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getObjectAttribute().getName()).isEqualTo("requestName"); + assertThat(persistedRecord.getObjectAttribute().getCity()).isEqualTo("originCity"); + } + + // ------------------------------------------------------- + // List-of-objects: three sources — PRIORITIZE_HIGHER_SOURCE + // ------------------------------------------------------- + @Test - public void updateExpressionInRequest_whenAttributeAlsoInExtension_shouldThrowDynamoDbError() { + public void updateItem_whenPrioritizeHigherSourceAndPojoExtensionRequestTouchObjectList_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( + expressionWithSetStringPath("objectListAttribute[1].name", "requestObject1")) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); + assertThat(persistedRecord.getObjectListAttribute().get(0).getName()).isEqualTo("originObject0"); + assertThat(persistedRecord.getObjectListAttribute().get(0).getCity()).isEqualTo("originCity0"); + assertThat(persistedRecord.getObjectListAttribute().get(1).getName()).isEqualTo("requestObject1"); + assertThat(persistedRecord.getObjectListAttribute().get(1).getCity()).isEqualTo("originCity1"); + } + + // ------------------------------------------------------- + // Extension vs request overlap on same scalar — LEGACY + // ------------------------------------------------------- + + @Test + public void updateItem_whenLegacyMergeAndExtensionAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { + initClientWithExtensions(new ItemPreservingUpdateExtension()); + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + + UpdateExpression conflictingExpression = expressionWithSetNumberAttribute("extensionNumberAttribute", 99L); + + assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(keyRecord) + .updateExpression(conflictingExpression) + .updateExpressionMergeStrategy(LEGACY))) + .isInstanceOf(DynamoDbException.class) + .hasMessageContaining("Two document paths"); + } + + // ------------------------------------------------------- + // Extension vs request overlap on same scalar — PRIORITIZE_HIGHER_SOURCE + // ------------------------------------------------------- + + @Test + public void updateItem_whenPrioritizeHigherSourceAndExtensionAndRequestSetSameScalar_thenRequestValueWins() { + initClientWithExtensions(new ItemPreservingUpdateExtension()); + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); + mappedTable.putItem(keyRecord); + + mappedTable.updateItem(r -> r.item(keyRecord) + .updateExpression(expressionWithSetNumberAttribute("extensionNumberAttribute", 99L)) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(99L); + } + + // ------------------------------------------------------- + // Different top-level names: PRIORITIZE_HIGHER_SOURCE keeps both actions + // ------------------------------------------------------- + + @Test + public void updateItem_whenPrioritizeHigherSourceAndTopLevelAttributesAreDisjoint_thenEachMutationAppliesIndependently() { + initClientWithExtensions(); + RecordForUpdateExpressions record = createFullRecord(); + mappedTable.putItem(record); + + RecordForUpdateExpressions updateRecord = createKeyOnlyRecord(); + updateRecord.setStringAttribute("updated"); + mappedTable.updateItem(r -> r.item(updateRecord) + .ignoreNulls(true) + .updateExpression(expressionWithSetListElement(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"); + } + + // ------------------------------------------------------- + // Extension vs request overlap (no explicit strategy) — defaults to LEGACY + // ------------------------------------------------------- + + @Test + public void updateItem_whenMergeStrategyNotProvidedAndExtensionAndRequestSetSameScalar_thenLegacyOverlapErrorIsReturned() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions recordForUpdateExpressions = createKeyOnlyRecord(); - // Create an UpdateExpression that conflicts with the extension's UpdateExpression - // Extension modifies extensionNumberAttribute, so we create a request expression that also modifies it UpdateExpression conflictingExpression = UpdateExpression.builder() .addAction(SetAction.builder() .path("extensionNumberAttribute") @@ -395,7 +582,7 @@ public void updateExpressionInRequest_whenAttributeAlsoInExtension_shouldThrowDy * providing an UpdateExpression on the request, behavior is identical ad before. */ @Test - public void backwardCompatibility_pojoOnlyUpdates() { + public void updateItem_withoutRequestExpression_pojoOnlyUpdate_preservesPriorBehavior() { initClientWithExtensions(); RecordForUpdateExpressions record = createSimpleRecord(); @@ -413,7 +600,7 @@ public void backwardCompatibility_pojoOnlyUpdates() { * without providing an UpdateExpression on the request, behavior is identical as before */ @Test - public void backwardCompatibility_extensionOnlyUpdates() { + public void updateItem_withoutRequestExpression_extensionOnlyUpdate_preservesPriorBehavior() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions record = createSimpleRecord(); @@ -429,7 +616,7 @@ public void backwardCompatibility_extensionOnlyUpdates() { * Tests scan() operation Verifies that scan operations work correctly after update expressions are applied. */ @Test - public void scanOperation_afterUpdateExpression() { + public void scanTable_reflectsPriorRequestExpressionOnListAttribute() { initClientWithExtensions(); RecordForUpdateExpressions record1 = createFullRecord(); record1.setId("scan1"); @@ -460,7 +647,7 @@ public void scanOperation_afterUpdateExpression() { * Tests deleteItem() operation Verifies that items can be deleted after being updated with expressions. */ @Test - public void deleteItem_afterUpdateExpression() { + public void deleteItem_afterRequestExpressionUpdate_removesItemSuccessfully() { initClientWithExtensions(); RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); @@ -486,7 +673,7 @@ public void deleteItem_afterUpdateExpression() { * Tests batchGetItem() operation Verifies that batch get operations work correctly after update expressions. */ @Test - public void batchGetItem_afterUpdateExpression() { + public void batchGetItem_reflectsPriorRequestExpressionUpdates() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -531,7 +718,7 @@ public void batchGetItem_afterUpdateExpression() { * applied. */ @Test - public void batchWriteItem_withUpdateExpressionItems() { + public void batchWriteItem_putAndDelete_afterRequestExpressionUpdate_completesSuccessfully() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -568,7 +755,7 @@ public void batchWriteItem_withUpdateExpressionItems() { * Tests transactGetItems() operation Verifies that transactional get operations work after update expressions. */ @Test - public void transactGetItems_afterUpdateExpression() { + public void transactGetItems_reflectsPriorRequestExpressionOnMatchingKey() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -610,7 +797,7 @@ public void transactGetItems_afterUpdateExpression() { * Tests transactWriteItems() operation Verifies that transactional write operations work correctly. */ @Test - public void transactWriteItems_withUpdateExpression() { + public void transactWriteItems_deleteAndPut_completesSuccessfully() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -639,11 +826,44 @@ public void transactWriteItems_withUpdateExpression() { assertThat(persistedRecord2.getRequestAttributeList()).containsExactly("attr1", "attr2"); } + // ------------------------------------------------------- + // Transactional update: request-level expression via TransactUpdateItemEnhancedRequest + // ------------------------------------------------------- + + @Test + public void transactWriteItems_transactUpdateWithRequestExpression_updatesListElement() { + 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( + expressionWithSetListElement(1, "txn")) + .build()) + .build()); + + RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); + assertThat(persistedRecord.getRequestAttributeList()).containsExactly("attr1", "txn"); + } + /** * Tests StaticTableSchema with UpdateExpression extensions */ @Test - public void staticTableSchema_withUpdateExpressions() { + public void updateItem_staticTableSchemaWithExtension_persistsAndReadsMergedState() { TableSchema staticSchema = TableSchema.builder(RecordForUpdateExpressions.class) .newItemSupplier(RecordForUpdateExpressions::new) .addAttribute(String.class, a -> a.name("id") @@ -727,9 +947,20 @@ 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); @@ -750,6 +981,31 @@ private UpdateExpression expressionWithSetListElement(int index, String value) { return UpdateExpression.builder().addAction(setListElement).build(); } + private UpdateExpression expressionWithSetNumberAttribute(String attributeName, long value) { + String valueRef = ":value_" + value; + SetAction setAction = SetAction.builder() + .path(attributeName) + .value(valueRef) + .putExpressionValue(valueRef, AttributeValue.builder().n(Long.toString(value)).build()) + .build(); + return UpdateExpression.builder().addAction(setAction).build(); + } + + private UpdateExpression expressionWithSetStringPath(String path, String value) { + String valueRef = ":str_" + value.replaceAll("[^a-zA-Z0-9]", "_"); + // Tokenize every path segment so reserved words (for example "name") are safe in UpdateExpression paths. + String tokenizedPath = path.replaceAll("([A-Za-z_][A-Za-z0-9_]*)", "#$1"); + Map expressionNames = Arrays.stream(path.replaceAll("\\[[0-9]+\\]", "").split("\\.")) + .collect(Collectors.toMap(segment -> "#" + segment, segment -> segment)); + SetAction setAction = SetAction.builder() + .path(tokenizedPath) + .value(valueRef) + .expressionNames(expressionNames) + .putExpressionValue(valueRef, AttributeValue.builder().s(value).build()) + .build(); + return UpdateExpression.builder().addAction(setAction).build(); + } + private static final class ItemPreservingUpdateExtension implements DynamoDbEnhancedClientExtension { private long incrementValue; private String valueRef; @@ -810,4 +1066,144 @@ 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") + .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/internal/operations/UpdateItemOperationTransactTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperationTransactTest.java index 2c72100138ca..855cc15c5547 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 @@ -37,6 +37,8 @@ import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.FakeItem; import software.amazon.awssdk.enhanced.dynamodb.model.TransactUpdateItemEnhancedRequest; import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest; +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; @@ -51,7 +53,7 @@ public class UpdateItemOperationTransactTest { private DynamoDbEnhancedClientExtension mockDynamoDbEnhancedClientExtension; @Test - public void generateTransactWriteItem_basicRequest() { + public void generateTransactWriteItem_wrapsGeneratedUpdateItemRequestInTransactUpdate() { FakeItem fakeItem = createUniqueFakeItem(); Map fakeItemMap = FakeItem.getTableSchema().itemToMap(fakeItem, true); UpdateItemOperation updateItemOperation = @@ -87,7 +89,7 @@ public void generateTransactWriteItem_basicRequest() { } @Test - public void generateTransactWriteItem_conditionalRequest() { + public void generateTransactWriteItem_includesConditionExpressionFromGeneratedRequest() { FakeItem fakeItem = createUniqueFakeItem(); Map fakeItemMap = FakeItem.getTableSchema().itemToMap(fakeItem, true); UpdateItemOperation updateItemOperation = @@ -126,7 +128,7 @@ public void generateTransactWriteItem_conditionalRequest() { } @Test - public void generateTransactWriteItem_returnValuesOnConditionCheckFailure_generatesCorrectRequest() { + public void generateTransactWriteItem_propagatesReturnValuesOnConditionCheckFailure() { FakeItem fakeItem = createUniqueFakeItem(); Map fakeItemMap = FakeItem.getTableSchema().itemToMap(fakeItem, true); String returnValues = "return-values"; @@ -155,6 +157,40 @@ public void generateTransactWriteItem_returnValuesOnConditionCheckFailure_genera verify(updateItemOperation).generateRequest(FakeItem.getTableSchema(), context, mockDynamoDbEnhancedClientExtension); } + @Test + public void generateRequest_transactUpdateWithSetExpression_emitsSameUpdateExpressionOnTransactWriteItem() { + 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")); + } + private UpdateItemRequest ddbRequest(Map keys, Consumer modify) { UpdateItemRequest.Builder builder = ddbBaseRequestBuilder(keys); 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 index 39c4fa5f99a0..afbf788133f9 100644 --- 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 @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertNull; import static org.mockito.Mockito.mock; +import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE; import java.util.Collections; import java.util.HashMap; @@ -25,6 +26,7 @@ import org.junit.Test; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; 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; @@ -290,82 +292,919 @@ public void resolve_attributeUsedInOtherExpression_filteredOutFromRemoveActions( } @Test - public void generateItemSetExpression_andFiltersNullValues() { + public void builder_allFields_buildsSuccessfully() { Map itemMap = new HashMap<>(); - itemMap.put("validItemAttrName", AttributeValue.builder().s("validItemAttrValue").build()); - itemMap.put("nullItemAttrName", AttributeValue.builder().nul(true).build()); + UpdateExpression extensionExpr = UpdateExpression.builder().build(); + UpdateExpression requestExpr = UpdateExpression.builder().build(); - UpdateExpression result = UpdateExpressionResolver.generateItemSetExpression(itemMap, mockTableMetadata); + UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpr) + .requestExpression(requestExpr) + .build(); - assertThat(result).isNotNull(); - assertThat(result.removeActions()).isEmpty(); - assertThat(result.addActions()).isEmpty(); - assertThat(result.deleteActions()).isEmpty(); + assertThat(resolver).isNotNull(); + } - assertThat(result.setActions()).isEqualTo(Collections.singletonList( + // ------------------------------------------------------- + // Null-safety: nonKeyAttributes not explicitly set + // ------------------------------------------------------- + + @Test + public void resolve_legacy_defaultBuilderWithoutNonKeyAttributes_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(mockTableMetadata) + .extensionExpression(extensionExpression) + .build() + .resolve(); + + assertThat(result).isEqualTo(extensionExpression); + } + + // ------------------------------------------------------- + // LEGACY mode: 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(mockTableMetadata) + .nonKeyAttributes(itemMap) + .requestExpression(requestExpression) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( SetAction.builder() - .path("#AMZN_MAPPED_validItemAttrName") - .value(":AMZN_MAPPED_validItemAttrName") - .putExpressionName("#AMZN_MAPPED_validItemAttrName", "validItemAttrName") - .putExpressionValue(":AMZN_MAPPED_validItemAttrName", - AttributeValue.builder().s("validItemAttrValue").build()) - .build())); + .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 generateItemRemoveExpression_includesOnlyNullValues() { + 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(mockTableMetadata) + .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()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE: three-source list overlap + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_listTouchedByPojoExtensionAndRequest_keepsRequestSetActionsOnly() { Map itemMap = new HashMap<>(); - itemMap.put("validItemAttrName", AttributeValue.builder().s("validItemAttrValue").build()); - itemMap.put("nullItemAttrName", AttributeValue.builder().nul(true).build()); + itemMap.put("list", AttributeValue.builder().l(AttributeValue.builder().s("pojo").build()).build()); - UpdateExpression result = UpdateExpressionResolver.generateItemRemoveExpression(itemMap, Collections.emptySet()); + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("list[0]") + .value(":extensionValue") + .putExpressionValue(":extensionValue", AttributeValue.builder().s("ext").build()) + .build()) + .build(); - assertThat(result).isNotNull(); - assertThat(result.setActions()).isEmpty(); - assertThat(result.addActions()).isEmpty(); - assertThat(result.deleteActions()).isEmpty(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("list[1]") + .value(":requestValue") + .putExpressionValue(":requestValue", AttributeValue.builder().s("req").build()) + .build()) + .build(); - assertThat(result.removeActions()).isEqualTo(Collections.singletonList( - RemoveAction.builder() - .path("#AMZN_MAPPED_nullItemAttrName") - .putExpressionName("#AMZN_MAPPED_nullItemAttrName", "nullItemAttrName") - .build())); + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy( + PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("list[1]") + .value(":requestValue") + .putExpressionValue(":requestValue", AttributeValue.builder().s("req").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE: sibling list indices (same top-level name) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_siblingListIndicesUnderSameAttribute_keepsRequestSetOnly() { + 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(mockTableMetadata) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy( + PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + 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 generateItemRemoveExpression_excludesNonRemovableAttributes() { + 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(mockTableMetadata) + .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("profile") + .value(":profile") + .putExpressionValue(":profile", + AttributeValue.builder().m(Collections.emptyMap()).build()) + .build()) + .build(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("profile.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("alice").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy( + PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("profile.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("alice").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("items[0]") + .value(":item0") + .putExpressionValue(":item0", + AttributeValue.builder().m(Collections.emptyMap()).build()) + .build()) + .build(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("items[0].name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("new-name").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy( + PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("items[0].name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("new-name").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE: nested object paths under same top-level name + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_objectNestedSiblingsUnderSameTopLevelName_keepsRequestOnly() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("profile.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("alice").build()) + .build()) + .build(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("profile.address.city") + .value(":city") + .putExpressionValue(":city", AttributeValue.builder().s("seattle").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy( + PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("profile.address.city") + .value(":city") + .putExpressionValue(":city", AttributeValue.builder().s("seattle").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE: POJO-extension only (no request) + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_pojoAndExtensionShareAttributeWithoutRequest_keepsExtensionActionsOnly() { Map itemMap = new HashMap<>(); - itemMap.put("nullItemAttr1Name", AttributeValue.builder().nul(true).build()); - itemMap.put("nullItemAttr2Name", AttributeValue.builder().nul(true).build()); + itemMap.put("counter", AttributeValue.builder().n("10").build()); - UpdateExpression result = UpdateExpressionResolver.generateItemRemoveExpression( - itemMap, Collections.singleton("nullItemAttr1Name")); + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("20").build()) + .build()) + .build(); - assertThat(result).isNotNull(); - assertThat(result.setActions()).isEmpty(); - assertThat(result.addActions()).isEmpty(); + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .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_keepsRequestActionsOnly() { + 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(mockTableMetadata) + .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("attrA", AttributeValue.builder().s("a").build()); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attrB") + .value(":b") + .putExpressionValue(":b", AttributeValue.builder().s("b").build()) + .build()) + .build(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attrC") + .value(":c") + .putExpressionValue(":c", AttributeValue.builder().s("c").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .nonKeyAttributes(itemMap) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy( + PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("attrC") + .value(":c") + .putExpressionValue(":c", AttributeValue.builder().s("c").build()) + .build(), + SetAction.builder() + .path("attrB") + .value(":b") + .putExpressionValue(":b", AttributeValue.builder().s("b").build()) + .build(), + SetAction.builder() + .path("#AMZN_MAPPED_attrA") + .value(":AMZN_MAPPED_attrA") + .putExpressionName("#AMZN_MAPPED_attrA", "attrA") + .putExpressionValue(":AMZN_MAPPED_attrA", AttributeValue.builder().s("a").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE: POJO REMOVE vs request SET + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_pojoRemoveVsRequestSet_keepsRequestSetOnly() { + Map itemMap = new HashMap<>(); + itemMap.put("attrX", AttributeValue.builder().nul(true).build()); + + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("attrX") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("new-value").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .nonKeyAttributes(itemMap) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy( + PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("attrX") + .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(mockTableMetadata) + .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_groupsByResolvedTopLevelName() { + 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(mockTableMetadata) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + 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(mockTableMetadata) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + 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_keepsRequestOnly() { + 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(mockTableMetadata) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("a.c") + .value(":c") + .putExpressionValue(":c", AttributeValue.builder().s("from-req").build()) + .build()); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE: all sources null → returns null + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_allSourcesNull_returnsNull() { + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + assertNull(result); + } + + // ------------------------------------------------------- + // 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(mockTableMetadata) + .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(mockTableMetadata) + .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(); + } - assertThat(result.removeActions()).isEqualTo(Collections.singletonList( - RemoveAction.builder() - .path("#AMZN_MAPPED_nullItemAttr2Name") - .putExpressionName("#AMZN_MAPPED_nullItemAttr2Name", "nullItemAttr2Name") - .build())); + // ------------------------------------------------------- + // 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(mockTableMetadata) + .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 builder_allFields_buildsSuccessfully() { + public void resolve_prioritizeHigherSource_pojoRemoveVsExtensionSet_keepsExtensionOnly() { Map itemMap = new HashMap<>(); - UpdateExpression extensionExpr = UpdateExpression.builder().build(); - UpdateExpression requestExpr = UpdateExpression.builder().build(); + itemMap.put("status", AttributeValue.builder().nul(true).build()); - UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) - .nonKeyAttributes(itemMap) - .extensionExpression(extensionExpr) - .requestExpression(requestExpr) - .build(); + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("status") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("active").build()) + .build()) + .build(); - assertThat(resolver).isNotNull(); + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .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(); + } + + // ------------------------------------------------------- + // PRIORITIZE_HIGHER_SOURCE: partial ownership — request owns one attr, extension owns another + // ------------------------------------------------------- + + @Test + public void resolve_prioritizeHigherSource_partialOwnership_eachSourceKeepsOwnedAttributes() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":extC") + .putExpressionValue(":extC", AttributeValue.builder().n("10").build()) + .build()) + .addAction(SetAction.builder() + .path("status") + .value(":extS") + .putExpressionValue(":extS", AttributeValue.builder().s("ext-status").build()) + .build()) + .build(); + UpdateExpression requestExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("99").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(mockTableMetadata) + .extensionExpression(extensionExpression) + .requestExpression(requestExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertThat(result.setActions()).containsExactlyInAnyOrder( + SetAction.builder() + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("99").build()) + .build(), + SetAction.builder() + .path("status") + .value(":extS") + .putExpressionValue(":extS", AttributeValue.builder().s("ext-status").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 index 893aecc4a340..88499df33640 100644 --- 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 @@ -34,7 +34,7 @@ public class UpdateExpressionUtilsTest { private final TableMetadata mockTableMetadata = mock(TableMetadata.class); @Test - public void ifNotExists_createsCorrectExpression() { + public void ifNotExists_mapsKeyAndValueToIfNotExistsExpression() { String result = UpdateExpressionUtils.ifNotExists("key", "value"); assertThat(result).isEqualTo("if_not_exists(#AMZN_MAPPED_key, :AMZN_MAPPED_value)"); @@ -90,7 +90,7 @@ public void setActionsFor_multipleAttributes_createsMultipleSetActions() { } @Test - public void setActionsFor_nestedAttribute_handlesCorrectly() { + public void setActionsFor_twoLevelDottedPath_producesSingleMappedSetAction() { Map attributes = new HashMap<>(); attributes.put("level1.level2", AttributeValue.builder().s("attrValue").build()); when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); @@ -107,7 +107,7 @@ public void setActionsFor_nestedAttribute_handlesCorrectly() { } @Test - public void setActionsFor_deeplyNestedAttribute_handlesCorrectly() { + public void setActionsFor_threeLevelDottedPath_producesSingleMappedSetAction() { Map attributes = new HashMap<>(); attributes.put("level1.level2.level3", AttributeValue.builder().s("attrValue").build()); when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); @@ -124,7 +124,7 @@ public void setActionsFor_deeplyNestedAttribute_handlesCorrectly() { } @Test - public void setActionsFor_attributeWithSpecialCharacters_handlesCorrectly() { + 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()); @@ -189,7 +189,7 @@ public void removeActionsFor_multipleAttributes_createsMultipleRemoveActions() { } @Test - public void removeActionsFor_nestedAttribute_handlesCorrectly() { + public void removeActionsFor_twoLevelDottedPath_producesSingleMappedRemoveAction() { Map attributes = new HashMap<>(); attributes.put("level1.level2", AttributeValue.builder().s("attrValue").build()); @@ -203,7 +203,7 @@ public void removeActionsFor_nestedAttribute_handlesCorrectly() { } @Test - public void removeActionsFor_deeplyNestedAttribute_handlesCorrectly() { + public void removeActionsFor_threeLevelDottedPath_producesSingleMappedRemoveAction() { Map attributes = new HashMap<>(); attributes.put("level1.level2.level3", AttributeValue.builder().s("attrValue").build()); @@ -217,7 +217,7 @@ public void removeActionsFor_deeplyNestedAttribute_handlesCorrectly() { } @Test - public void removeActionsFor_attributeWithSpecialCharacters_handlesCorrectly() { + 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()); 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..1013595ab345 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; @@ -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 @@ -155,8 +158,6 @@ public void equals_conditionExpressionNotEqual() { .putExpressionValue(":value1", stringValue("three")) .build(); - - TransactUpdateItemEnhancedRequest builtObject1 = TransactUpdateItemEnhancedRequest.builder(FakeItem.class).conditionExpression(conditionExpression1).build(); @@ -284,4 +285,14 @@ public void builder_returnValuesOnConditionCheckFailureNewValue_stringGetter() { .build(); assertThat(builtObject.returnValuesOnConditionCheckFailureAsString(), is(returnValues)); } + + @Test + public void toBuilder_roundTrip_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..2fa36f699995 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 builder_defaults_optionalFieldsNull_mergeStrategyLegacy() { 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 @@ -85,7 +88,7 @@ public void builder_maximal() { } @Test - public void toBuilder() { + public void toBuilder_roundTrip_equalsOriginal() { FakeItem fakeItem = createUniqueFakeItem(); Expression conditionExpression = Expression.builder() @@ -304,4 +307,14 @@ public void hashCode_returnValuesOnConditionCheckFailure() { assertThat(containsItem.hashCode(), not(equalTo(emptyRequest.hashCode()))); } + + @Test + public void toBuilder_roundTrip_preservesPrioritizeHigherSourceMergeStrategy() { + UpdateItemEnhancedRequest request = + UpdateItemEnhancedRequest.builder(FakeItem.class) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build(); + + assertThat(request.toBuilder().build().updateExpressionMergeStrategy(), is(PRIORITIZE_HIGHER_SOURCE)); + } } From 4031a28a85c45d37ea25a5fa0f4694264b44a794 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Fri, 27 Mar 2026 09:10:23 +0200 Subject: [PATCH 5/9] Addressed PR feedback --- .../update/UpdateExpressionResolver.java | 83 +- .../update/UpdateExpressionUtils.java | 58 ++ .../functionaltests/UpdateExpressionTest.java | 640 ++++++------ .../models/RecordForUpdateExpressions.java | 85 -- .../UpdateItemOperationTransactTest.java | 14 +- .../update/UpdateExpressionResolverTest.java | 941 +++++++++++------- .../update/UpdateExpressionUtilsTest.java | 137 ++- ...TransactUpdateItemEnhancedRequestTest.java | 6 +- .../model/UpdateItemEnhancedRequestTest.java | 6 +- 9 files changed, 1101 insertions(+), 869 deletions(-) delete mode 100644 services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordForUpdateExpressions.java 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 index 65d3b4a7f248..24efb24aa9d4 100644 --- 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 @@ -16,16 +16,14 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.update; import static java.util.Objects.requireNonNull; -import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter.findAttributeNames; -import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter.removeNestingAndListReference; -import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.removeActionsFor; -import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.setActionsFor; -import static software.amazon.awssdk.utils.CollectionUtils.filterMap; +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.resolveTopLevelAttributeName; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -33,12 +31,10 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -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.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; @@ -83,8 +79,8 @@ public static Builder builder() { * *

    *
  • {@link UpdateExpressionMergeStrategy#LEGACY} (default) — concatenates all actions as-is; - * overlapping paths cause a DynamoDB runtime error. Null-attribute REMOVE actions are suppressed when the - * same attribute appears in an extension or request expression.
  • + * overlapping paths cause a DynamoDB runtime error. As in previous behavior, null-attribute REMOVE actions + * are suppressed when the same attribute appears in an extension or request expression. * *
  • {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE} — groups actions by top-level * attribute name (path before first {@code .} or {@code [}). For each name, only the highest-priority @@ -118,31 +114,24 @@ public UpdateExpression resolve() { .orElse(null); } - private static Set attributesPresentInOtherExpressions(Collection updateExpressions) { - return updateExpressions.stream() - .filter(Objects::nonNull) - .map(UpdateExpressionConverter::findAttributeNames) - .flatMap(List::stream) - .collect(Collectors.toSet()); + TableMetadata tableMetadata() { + return tableMetadata; } - private static UpdateExpression generateItemSetExpression(Map itemMap, - TableMetadata tableMetadata) { + Map nonKeyAttributes() { + return nonKeyAttributes; + } - Map setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue())); - return UpdateExpression.builder() - .actions(setActionsFor(setAttributes, tableMetadata)) - .build(); + UpdateExpression extensionExpression() { + return extensionExpression; } - private static UpdateExpression generateItemRemoveExpression(Map itemMap, - Collection nonRemoveAttributes) { - Map removeAttributes = - filterMap(itemMap, e -> isNullAttributeValue(e.getValue()) && !nonRemoveAttributes.contains(e.getKey())); + UpdateExpression requestExpression() { + return requestExpression; + } - return UpdateExpression.builder() - .actions(removeActionsFor(removeAttributes)) - .build(); + UpdateExpressionMergeStrategy updateExpressionMergeStrategy() { + return updateExpressionMergeStrategy; } /** @@ -184,19 +173,19 @@ private static UpdateExpression filterByAttributes(UpdateExpression expression, List retainedActions = new ArrayList<>(); expression.setActions().stream() - .filter(action -> attributeNames.contains(baseAttributeName(action.path(), action.expressionNames()))) + .filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames()))) .forEach(retainedActions::add); expression.removeActions().stream() - .filter(action -> attributeNames.contains(baseAttributeName(action.path(), action.expressionNames()))) + .filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames()))) .forEach(retainedActions::add); expression.deleteActions().stream() - .filter(action -> attributeNames.contains(baseAttributeName(action.path(), action.expressionNames()))) + .filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames()))) .forEach(retainedActions::add); expression.addActions().stream() - .filter(action -> attributeNames.contains(baseAttributeName(action.path(), action.expressionNames()))) + .filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames()))) .forEach(retainedActions::add); return retainedActions.isEmpty() @@ -204,34 +193,6 @@ private static UpdateExpression filterByAttributes(UpdateExpression expression, : UpdateExpression.builder().actions(retainedActions).build(); } - /** - * Returns the root DynamoDB attribute name for an update action path, used when merging expressions (for example to - * decide which actions belong together or overlap). - *

    - * The path is resolved in two steps: - *

      - *
    1. Substitute {@link SetAction#expressionNames() expression name} placeholders: each entry's key is replaced by its - * value in {@code fullAttributePath} (in map iteration order), as with {@code ExpressionAttributeNames}.
    2. - *
    3. {@link UpdateExpressionConverter#removeNestingAndListReference(String)} then takes the segment before the first - * {@code .} (nested map) or {@code [} (list index)—the top-level attribute stored on the item.
    4. - *
    - *

    - * Examples (after any placeholder substitution): - *

      - *
    • {@code list[0]} → {@code list}
    • - *
    • {@code object.listAttr[0]} → {@code object}
    • - *
    - */ - private static String baseAttributeName(String fullAttributePath, Map expressionNames) { - String result = fullAttributePath; - - Map names = expressionNames != null ? expressionNames : Collections.emptyMap(); - for (Map.Entry entry : names.entrySet()) { - result = result.replace(entry.getKey(), entry.getValue()); - } - return removeNestingAndListReference(result); - } - public static final class Builder { private TableMetadata tableMetadata; 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 3d12095160d6..f7cc97fde15d 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java @@ -15,14 +15,20 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.update; +import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter.removeNestingAndListReference; +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; @@ -33,6 +39,7 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior; import software.amazon.awssdk.enhanced.dynamodb.update.RemoveAction; import software.amazon.awssdk.enhanced.dynamodb.update.SetAction; +import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; @SdkInternalApi @@ -50,6 +57,31 @@ public static String ifNotExists(String key, String initValue) { return "if_not_exists(" + keyRef(key) + ", " + valueRef(initValue) + ")"; } + /** + * SET actions for every {@code itemMap} entry that is not DynamoDB NULL. For each attribute, {@link UpdateBehavior} + * is resolved from {@code tableMetadata}. + */ + static UpdateExpression generateItemSetExpression(Map itemMap, + TableMetadata tableMetadata) { + Map setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue())); + return UpdateExpression.builder() + .actions(setActionsFor(setAttributes, tableMetadata)) + .build(); + } + + /** + * 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. */ @@ -72,6 +104,32 @@ static List removeActionsFor(Map attribute .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 action path to its top-level DynamoDB attribute name by first replacing expression-name tokens and then + * removing any nested path or list-index suffix. + */ + static String resolveTopLevelAttributeName(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 removeNestingAndListReference(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/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 3294a7b626d3..57e30901c1e8 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 @@ -3,7 +3,6 @@ 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.StaticAttributeTags.primaryPartitionKey; 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; @@ -34,6 +33,7 @@ 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; @@ -43,6 +43,23 @@ 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 → request wins
    • + *
    • PRIORITIZE_HIGHER_SOURCE, list of maps, three sources → request nested update wins
    • + *
    • 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")); @@ -57,7 +74,8 @@ public class UpdateExpressionTest extends LocalDynamoDbSyncTestBase { private static final String SET_ATTRIBUTE_REF = "extensionSetAttribute"; private static final String TABLE_NAME = "table-name"; - private static final TableSchema TABLE_SCHEMA = TableSchema.fromClass(RecordForUpdateExpressions.class); + private static final TableSchema TABLE_SCHEMA = + TableSchema.fromClass(RecordForUpdateExpressions.class); private DynamoDbTable mappedTable; private void initClientWithExtensions(DynamoDbEnhancedClientExtension... extensions) { @@ -78,8 +96,10 @@ public void deleteTable() { } } + // --- Atomic counter (SET with if_not_exists + add) --- + @Test - public void updateItem_ifNotExistsPlusIncrement_whenCounterAbsent_persistsRequestedIncrement() { + public void updateItem_givenCounterAbsent_whenIfNotExistsIncrementExpression_thenStoresIncrement() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createKeyOnlyRecord(); @@ -88,29 +108,30 @@ public void updateItem_ifNotExistsPlusIncrement_whenCounterAbsent_persistsReques 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(); + 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)); + mappedTable.updateItem(r -> r.item(keyRecord).updateExpression(updateExpression)); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); assertThat(persistedRecord.getIncrementedAttribute()).isEqualTo(30L); } @Test - public void updateItem_ifNotExistsPlusIncrement_whenCounterPresent_persistsSumWithIncrement() { + public void updateItem_givenCounterPresent_whenIfNotExistsIncrementExpression_thenAddsToExistingValue() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createKeyOnlyRecord(); @@ -120,16 +141,18 @@ public void updateItem_ifNotExistsPlusIncrement_whenCounterPresent_persistsSumWi 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(); + 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"); @@ -141,8 +164,10 @@ public void updateItem_ifNotExistsPlusIncrement_whenCounterPresent_persistsSumWi assertThat(persistedRecord.getIncrementedAttribute()).isEqualTo(40L); } + // --- Extension vs POJO: preserving / filtering extensions --- + @Test - public void attribute_notInPojo_notFilteredInExtension_ignoresNulls_updatesNormally() { + public void updateItem_givenPreservingExtension_attributeAbsentFromPojo_whenIgnoreNullsTrue_thenExtensionFieldsUnchanged() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions record = createFullRecord(); @@ -154,18 +179,16 @@ 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. + * 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. *

    - * 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. + * By default, ignoreNulls 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. */ @Test - public void attribute_notInPojo_notFilteredInExtension_defaultSetsNull_updatesNormally() { + public void updateItem_givenPreservingExtension_attributeAbsentFromPojo_whenIgnoreNullsFalse_thenRemoveSuppressedForExtensionPath() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions record = createFullRecord(); @@ -177,7 +200,7 @@ 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 = createFullRecord(); mappedTable.putItem(record); @@ -189,7 +212,7 @@ 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 = createFullRecord(); mappedTable.putItem(record); @@ -201,11 +224,11 @@ 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 = createFullRecord(); record.setExtensionNumberAttribute(100L); @@ -214,7 +237,7 @@ public void attribute_inPojo_notFilteredInExtension_ignoresNulls_ddbError() { } @Test - public void attribute_inPojo_notFilteredInExtension_defaultSetsNull_ddbError() { + public void updateItem_givenPreservingExtension_attributeInPojo_whenIgnoreNullsFalse_thenDynamoDbRejectsOverlappingPaths() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions record = createFullRecord(); record.setExtensionNumberAttribute(100L); @@ -223,12 +246,11 @@ public void attribute_inPojo_notFilteredInExtension_defaultSetsNull_ddbError() { } /** - * 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 = createFullRecord(); mappedTable.putItem(record); @@ -241,7 +263,7 @@ 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 = createFullRecord(); mappedTable.putItem(record); @@ -253,8 +275,10 @@ 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 = createFullRecord(); putRecord.setExtensionNumberAttribute(11L); @@ -271,40 +295,42 @@ 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(createFullRecord(), false); } @Test - public void chainedExtensions_duplicateAttributes_sameValue_differentValueRef_ddbError() { - initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemPreservingUpdateExtension(NUMBER_ATTRIBUTE_VALUE, ":ref")); + 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(createFullRecord(), false); } @Test - public void chainedExtensions_duplicateAttributes_differentValue_sameValueRef_operationMergeError() { - initClientWithExtensions(new ItemPreservingUpdateExtension(), new ItemPreservingUpdateExtension(10L, NUMBER_ATTRIBUTE_VALUE_REF)); + 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 = 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"); @@ -315,15 +341,17 @@ public void chainedExtensions_duplicateAttributes_invalidValueRef_operationMerge * 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_requestExpressionSetListElement_ignoreNullsFalse_avoidsRemoveConflictsAndUpdatesList() { + public void updateItem_givenRequestExpressionSetsListIndex_whenIgnoreNullsFalse_thenListUpdatedWithoutRemoveConflict() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createFullRecord(); putInitialItemAndVerify(initialRecord); RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); - mappedTable.updateItem(r -> r.item(keyRecord) - .updateExpression(expressionWithSetListElement(1, "attr3"))); + mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(updateExpressionSetRequestListElement(1, "attr3"))); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); List requestAttributeList = persistedRecord.getRequestAttributeList(); @@ -336,15 +364,16 @@ public void updateItem_requestExpressionSetListElement_ignoreNullsFalse_avoidsRe * operates independently of the ignoreNulls setting and updates the specified attributes. */ @Test - public void updateItem_requestExpressionSetListElement_ignoreNullsTrue_updatesStoredList() { + public void updateItem_givenRequestExpressionSetsListIndex_whenIgnoreNullsTrue_thenListUpdated() { initClientWithExtensions(); RecordForUpdateExpressions initialRecord = createFullRecord(); putInitialItemAndVerify(initialRecord); RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); - mappedTable.updateItem(r -> r.item(keyRecord) - .ignoreNulls(true) - .updateExpression(expressionWithSetListElement(1, "attr3"))); + mappedTable.updateItem( + r -> r.item(keyRecord) + .ignoreNulls(true) + .updateExpression(updateExpressionSetRequestListElement(1, "attr3"))); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); List requestAttributeList = persistedRecord.getRequestAttributeList(); @@ -356,124 +385,136 @@ public void updateItem_requestExpressionSetListElement_ignoreNullsTrue_updatesSt * UpdateExpression provided on the request */ @Test - public void updateItem_requestExpressionOverlapsPojoAttribute_dynamoDbRejectsOverlappingPaths() { + 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(expressionWithSetListElement(1, "attr3")))) + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(updateRecord) + .updateExpression(updateExpressionSetRequestListElement(1, "attr3")))) .isInstanceOf(DynamoDbException.class) .hasMessageContaining("Two document paths overlap"); } - // ------------------------------------------------------- - // Simple scalar attribute: POJO vs request — LEGACY - // ------------------------------------------------------- + // --- Merge strategy: scalar (POJO vs request) --- @Test - public void updateItem_whenLegacyMergeAndPojoAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { + public void updateItem_givenLegacyMergeStrategy_whenPojoAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { initClientWithExtensions(); RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setExtensionNumberAttribute(100L); - UpdateExpression requestExpression = expressionWithSetNumberAttribute("extensionNumberAttribute", 200L); + UpdateExpression requestExpression = updateExpressionSetLongAttribute("extensionNumberAttribute", 200L); - assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(record) - .updateExpression(requestExpression) - .updateExpressionMergeStrategy(LEGACY))) + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(record) + .updateExpression(requestExpression) + .updateExpressionMergeStrategy(LEGACY))) .isInstanceOf(DynamoDbException.class) .hasMessageContaining("Two document paths"); } - // ------------------------------------------------------- - // Simple scalar attribute: POJO vs request — PRIORITIZE_HIGHER_SOURCE - // ------------------------------------------------------- - @Test - public void updateItem_whenPrioritizeHigherSourceAndPojoAndRequestSetSameScalar_thenRequestValueWins() { + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoAndRequestSetSameScalar_thenRequestValuePersists() { initClientWithExtensions(); RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setExtensionNumberAttribute(100L); - mappedTable.updateItem(r -> r.item(record) - .updateExpression(expressionWithSetNumberAttribute("extensionNumberAttribute", 200L)) - .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + mappedTable.updateItem( + r -> r.item(record) + .updateExpression(updateExpressionSetLongAttribute("extensionNumberAttribute", 200L)) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(200L); } - // ------------------------------------------------------- - // List paths: three sources (POJO root, extension [0], request [1]) — PRIORITIZE_HIGHER_SOURCE - // ------------------------------------------------------- + // --- Merge strategy: list (POJO + extension + request) --- @Test - public void updateItem_whenPrioritizeHigherSourceAndPojoExtensionRequestTouchSameList_thenRequestMutationWins() { + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoExtensionAndRequestTouchSameList_thenRequestWins() { initClientWithExtensions(new ListFirstElementUpdateExtension()); RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setRequestAttributeList(Arrays.asList("pojo1", "pojo2")); - mappedTable.updateItem(r -> r.item(record) - .updateExpression(expressionWithSetListElement(1, "request1")) - .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + mappedTable.updateItem( + r -> r.item(record) + .updateExpression(updateExpressionSetRequestListElement(1, "request1")) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); assertThat(persistedRecord.getRequestAttributeList()).containsExactly("attr1", "request1"); } - // ------------------------------------------------------- - // Object parent/child: POJO root vs request nested path — LEGACY - // ------------------------------------------------------- + // --- Merge strategy: document path (root vs nested) --- @Test - public void updateItem_whenLegacyMergeAndPojoSetsObjectRootAndRequestSetsNestedField_thenDynamoDbRejectsOverlap() { + 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( - expressionWithSetStringPath("objectAttribute.name", - "requestName")) - .updateExpressionMergeStrategy(LEGACY))) + 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"); } - // ------------------------------------------------------- - // Object parent/child: POJO root vs request nested path — PRIORITIZE_HIGHER_SOURCE - // ------------------------------------------------------- - @Test - public void updateItem_whenPrioritizeHigherSourceAndPojoSetsObjectRootAndRequestSetsNestedField_thenNestedRequestValuePersists() { + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoSetsDocumentRootAndRequestSetsNestedField_thenNestedPathPersists() { initClientWithExtensions(); RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); record.setObjectAttribute(nestedObject("pojoName", "pojoCity")); - mappedTable.updateItem(r -> r.item(record) - .updateExpression(expressionWithSetStringPath("objectAttribute.name", "requestName")) - .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + 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"); } - // ------------------------------------------------------- - // List-of-objects: three sources — PRIORITIZE_HIGHER_SOURCE - // ------------------------------------------------------- + // --- Merge strategy: list of maps (three sources) --- @Test - public void updateItem_whenPrioritizeHigherSourceAndPojoExtensionRequestTouchObjectList_thenRequestNestedUpdatePersists() { + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoExtensionAndRequestTouchObjectList_thenRequestNestedUpdatePersists() { initClientWithExtensions(new ObjectListFirstElementNameUpdateExtension()); RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); @@ -481,10 +522,22 @@ public void updateItem_whenPrioritizeHigherSourceAndPojoExtensionRequestTouchObj record.setObjectListAttribute(Arrays.asList( nestedObject("pojo0", "pojoCity0"), nestedObject("pojo1", "pojoCity1"))); - mappedTable.updateItem(r -> r.item(record) - .updateExpression( - expressionWithSetStringPath("objectListAttribute[1].name", "requestObject1")) - .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + 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("originObject0"); @@ -493,58 +546,52 @@ public void updateItem_whenPrioritizeHigherSourceAndPojoExtensionRequestTouchObj assertThat(persistedRecord.getObjectListAttribute().get(1).getCity()).isEqualTo("originCity1"); } - // ------------------------------------------------------- - // Extension vs request overlap on same scalar — LEGACY - // ------------------------------------------------------- + // --- Merge strategy: extension vs request (same scalar) --- @Test - public void updateItem_whenLegacyMergeAndExtensionAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { + public void updateItem_givenLegacyMergeStrategy_whenExtensionAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); - UpdateExpression conflictingExpression = expressionWithSetNumberAttribute("extensionNumberAttribute", 99L); + UpdateExpression conflictingExpression = + updateExpressionSetLongAttribute("extensionNumberAttribute", 99L); - assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(keyRecord) - .updateExpression(conflictingExpression) - .updateExpressionMergeStrategy(LEGACY))) + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(conflictingExpression) + .updateExpressionMergeStrategy(LEGACY))) .isInstanceOf(DynamoDbException.class) .hasMessageContaining("Two document paths"); } - // ------------------------------------------------------- - // Extension vs request overlap on same scalar — PRIORITIZE_HIGHER_SOURCE - // ------------------------------------------------------- - @Test - public void updateItem_whenPrioritizeHigherSourceAndExtensionAndRequestSetSameScalar_thenRequestValueWins() { + public void updateItem_givenPrioritizeHigherSourceMerge_whenExtensionAndRequestSetSameScalar_thenRequestValuePersists() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); mappedTable.putItem(keyRecord); - mappedTable.updateItem(r -> r.item(keyRecord) - .updateExpression(expressionWithSetNumberAttribute("extensionNumberAttribute", 99L)) - .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(updateExpressionSetLongAttribute("extensionNumberAttribute", 99L)) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(99L); } - // ------------------------------------------------------- - // Different top-level names: PRIORITIZE_HIGHER_SOURCE keeps both actions - // ------------------------------------------------------- - @Test - public void updateItem_whenPrioritizeHigherSourceAndTopLevelAttributesAreDisjoint_thenEachMutationAppliesIndependently() { + 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(expressionWithSetListElement(0, "reqVal")) - .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); + 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. @@ -552,37 +599,37 @@ public void updateItem_whenPrioritizeHigherSourceAndTopLevelAttributesAreDisjoin assertThat(persistedRecord.getRequestAttributeList().get(0)).isEqualTo("reqVal"); } - // ------------------------------------------------------- - // Extension vs request overlap (no explicit strategy) — defaults to LEGACY - // ------------------------------------------------------- - @Test - public void updateItem_whenMergeStrategyNotProvidedAndExtensionAndRequestSetSameScalar_thenLegacyOverlapErrorIsReturned() { + public void updateItem_givenDefaultMergeStrategy_whenExtensionAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions recordForUpdateExpressions = createKeyOnlyRecord(); - UpdateExpression conflictingExpression = UpdateExpression.builder() - .addAction(SetAction.builder() - .path("extensionNumberAttribute") - .value(":conflictValue") - .putExpressionValue(":conflictValue", - AttributeValue.builder().n("99").build()) - .build()) - .build(); - - assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(recordForUpdateExpressions) - .updateExpression(conflictingExpression))) + UpdateExpression conflictingExpression = + UpdateExpression.builder() + .addAction( + SetAction.builder() + .path("extensionNumberAttribute") + .value(":conflictValue") + .putExpressionValue( + ":conflictValue", + AttributeValue.builder().n("99").build()) + .build()) + .build(); + + assertThatThrownBy(() -> mappedTable.updateItem( + r -> r.item(recordForUpdateExpressions).updateExpression(conflictingExpression))) .isInstanceOf(DynamoDbException.class) .hasMessageContaining("Two document paths") .hasMessageContaining(NUMBER_ATTRIBUTE_REF); } + // --- Backward compatibility (no request-level UpdateExpression) --- + /** - * Tests backward compatibility: POJO-only updates should work unchanged. UpdateExpression functionality is opt-in - without - * providing an UpdateExpression on the request, behavior is identical ad before. + * POJO-only updates are unchanged. Request-level UpdateExpression is opt-in; without it, behavior matches earlier releases. */ @Test - public void updateItem_withoutRequestExpression_pojoOnlyUpdate_preservesPriorBehavior() { + public void updateItem_givenNoRequestExpression_whenPojoOnlyUpdate_thenScalarPersists() { initClientWithExtensions(); RecordForUpdateExpressions record = createSimpleRecord(); @@ -596,11 +643,10 @@ public void updateItem_withoutRequestExpression_pojoOnlyUpdate_preservesPriorBeh } /** - * Tests backward compatibility: Extension-only updates should work unchanged. UpdateExpression functionality is opt-in - - * without providing an UpdateExpression on the request, behavior is identical as before + * Backward compatibility: extension-only updates are unchanged when no request-level UpdateExpression is supplied. */ @Test - public void updateItem_withoutRequestExpression_extensionOnlyUpdate_preservesPriorBehavior() { + public void updateItem_givenNoRequestExpression_whenExtensionOnlyUpdate_thenExtensionValuePersists() { initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions record = createSimpleRecord(); @@ -612,11 +658,13 @@ public void updateItem_withoutRequestExpression_extensionOnlyUpdate_preservesPri assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(5L); } + // --- Other APIs after request-level UpdateExpression (scan, batch, transact) --- + /** - * Tests scan() operation Verifies that scan operations work correctly after update expressions are applied. + * Verifies {@code scan} returns items updated earlier with a request-level UpdateExpression. */ @Test - public void scanTable_reflectsPriorRequestExpressionOnListAttribute() { + public void scan_givenRequestExpressionUpdatedList_whenScan_thenReadItemReflectsUpdate() { initClientWithExtensions(); RecordForUpdateExpressions record1 = createFullRecord(); record1.setId("scan1"); @@ -629,11 +677,13 @@ public void scanTable_reflectsPriorRequestExpressionOnListAttribute() { // 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(expressionWithSetListElement(0, "updated"))); + mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(updateExpressionSetRequestListElement(0, "updated"))); // Scan and verify both records - List scannedItems = mappedTable.scan().items().stream().collect(Collectors.toList()); + List scannedItems = + mappedTable.scan().items().stream().collect(Collectors.toList()); assertThat(scannedItems).hasSize(2); RecordForUpdateExpressions updatedRecord = scannedItems.stream() @@ -644,18 +694,19 @@ public void scanTable_reflectsPriorRequestExpressionOnListAttribute() { } /** - * Tests deleteItem() operation Verifies that items can be deleted after being updated with expressions. + * Verifies {@code deleteItem} succeeds after an update that used a request-level UpdateExpression. */ @Test - public void deleteItem_afterRequestExpressionUpdate_removesItemSuccessfully() { + 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(expressionWithSetListElement(0, "beforeDelete"))); + mappedTable.updateItem( + r -> r.item(keyRecord) + .updateExpression(updateExpressionSetRequestListElement(0, "beforeDelete"))); // Verify update worked RecordForUpdateExpressions updatedRecord = mappedTable.getItem(record); @@ -670,10 +721,10 @@ public void deleteItem_afterRequestExpressionUpdate_removesItemSuccessfully() { } /** - * Tests batchGetItem() operation Verifies that batch get operations work correctly after update expressions. + * Verifies {@code batchGetItem} returns items updated with request-level UpdateExpressions. */ @Test - public void batchGetItem_reflectsPriorRequestExpressionUpdates() { + public void batchGetItem_givenRequestExpressionUpdatedTwoItems_whenBatchGet_thenBothReflectUpdates() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -690,23 +741,28 @@ public void batchGetItem_reflectsPriorRequestExpressionUpdates() { // 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(expressionWithSetListElement(0, "batch1Updated"))); + mappedTable.updateItem( + r -> r.item(keyRecord1) + .updateExpression(updateExpressionSetRequestListElement(0, "batch1Updated"))); RecordForUpdateExpressions keyRecord2 = createKeyOnlyRecord(); keyRecord2.setId("batch2"); - mappedTable.updateItem(r -> r.item(keyRecord2) - .updateExpression(expressionWithSetListElement(0, "batch2Updated"))); + 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()); + 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))) @@ -714,11 +770,10 @@ public void batchGetItem_reflectsPriorRequestExpressionUpdates() { } /** - * Tests batchWriteItem() operation Verifies that batch write operations work with items that have update expressions - * applied. + * Verifies {@code batchWriteItem} with put and delete after a request-level UpdateExpression update. */ @Test - public void batchWriteItem_putAndDelete_afterRequestExpressionUpdate_completesSuccessfully() { + public void batchWriteItem_givenUpdatedItemAndPutDelete_whenBatchWrite_thenPutVisibleAndDeleteSucceeds() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -733,8 +788,9 @@ public void batchWriteItem_putAndDelete_afterRequestExpressionUpdate_completesSu mappedTable.putItem(record1); RecordForUpdateExpressions keyRecord1 = createKeyOnlyRecord(); keyRecord1.setId("batchWrite1"); - mappedTable.updateItem(r -> r.item(keyRecord1) - .updateExpression(expressionWithSetListElement(0, "preWrite"))); + mappedTable.updateItem( + r -> r.item(keyRecord1) + .updateExpression(updateExpressionSetRequestListElement(0, "preWrite"))); // Batch write new record and delete updated record enhancedClient.batchWriteItem(r -> r.writeBatches( @@ -752,10 +808,10 @@ public void batchWriteItem_putAndDelete_afterRequestExpressionUpdate_completesSu } /** - * Tests transactGetItems() operation Verifies that transactional get operations work after update expressions. + * Verifies {@code transactGetItems} returns an item updated with a request-level UpdateExpression. */ @Test - public void transactGetItems_reflectsPriorRequestExpressionOnMatchingKey() { + public void transactGetItems_givenRequestExpressionUpdatedItem_whenTransactGet_thenUpdatedFieldReturned() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -772,18 +828,20 @@ public void transactGetItems_reflectsPriorRequestExpressionOnMatchingKey() { // Update with expressions using key-only record to avoid path conflicts RecordForUpdateExpressions keyRecord1 = createKeyOnlyRecord(); keyRecord1.setId("transact1"); - mappedTable.updateItem(r -> r.item(keyRecord1) - .updateExpression(expressionWithSetListElement(0, "transactUpdated"))); + 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()); + 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() @@ -794,10 +852,10 @@ public void transactGetItems_reflectsPriorRequestExpressionOnMatchingKey() { } /** - * Tests transactWriteItems() operation Verifies that transactional write operations work correctly. + * Verifies {@code transactWriteItems} delete + put in one transaction. */ @Test - public void transactWriteItems_deleteAndPut_completesSuccessfully() { + public void transactWriteItems_givenDeleteAndPutInTransaction_whenExecute_thenOldGoneNewPresent() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -826,12 +884,10 @@ public void transactWriteItems_deleteAndPut_completesSuccessfully() { assertThat(persistedRecord2.getRequestAttributeList()).containsExactly("attr1", "attr2"); } - // ------------------------------------------------------- - // Transactional update: request-level expression via TransactUpdateItemEnhancedRequest - // ------------------------------------------------------- + // --- Transact update with request-level UpdateExpression --- @Test - public void transactWriteItems_transactUpdateWithRequestExpression_updatesListElement() { + public void transactWriteItems_givenTransactUpdateWithRequestExpression_whenExecute_thenListElementUpdated() { initClientWithExtensions(); DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() .dynamoDbClient(getDynamoDbClient()) @@ -848,63 +904,17 @@ public void transactWriteItems_transactUpdateWithRequestExpression_updatesListEl TransactWriteItemsEnhancedRequest.builder() .addUpdateItem( mappedTable, - TransactUpdateItemEnhancedRequest.builder(RecordForUpdateExpressions.class) - .item(keyRecord) - .updateExpression( - expressionWithSetListElement(1, "txn")) - .build()) + TransactUpdateItemEnhancedRequest + .builder(RecordForUpdateExpressions.class) + .item(keyRecord) + .updateExpression(updateExpressionSetRequestListElement(1, "txn")) + .build()) .build()); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(keyRecord); assertThat(persistedRecord.getRequestAttributeList()).containsExactly("attr1", "txn"); } - /** - * Tests StaticTableSchema with UpdateExpression extensions - */ - @Test - public void updateItem_staticTableSchemaWithExtension_persistsAndReadsMergedState() { - TableSchema staticSchema = TableSchema.builder(RecordForUpdateExpressions.class) - .newItemSupplier(RecordForUpdateExpressions::new) - .addAttribute(String.class, a -> a.name("id") - .getter(RecordForUpdateExpressions::getId) - .setter(RecordForUpdateExpressions::setId) - .tags(primaryPartitionKey())) - .addAttribute(String.class, a -> a.name( - "stringAttribute") - .getter(RecordForUpdateExpressions::getStringAttribute) - .setter(RecordForUpdateExpressions::setStringAttribute)) - .addAttribute(Long.class, a -> a.name( - "extensionNumberAttribute") - .getter(RecordForUpdateExpressions::getExtensionNumberAttribute) - .setter(RecordForUpdateExpressions::setExtensionNumberAttribute)) - .build(); - - DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(getDynamoDbClient()) - .extensions(new ItemPreservingUpdateExtension()) - .build(); - - String staticTableName = getConcreteTableName("static-table"); - DynamoDbTable staticTable = enhancedClient.table(staticTableName, staticSchema); - - try { - staticTable.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); - - RecordForUpdateExpressions record = new RecordForUpdateExpressions(); - record.setId("static-test"); - record.setStringAttribute("init"); - - staticTable.updateItem(r -> r.item(record)); - - RecordForUpdateExpressions persistedRecord = staticTable.getItem(record); - assertThat(persistedRecord.getStringAttribute()).isEqualTo("init"); - assertThat(persistedRecord.getExtensionNumberAttribute()).isEqualTo(5L); - } finally { - getDynamoDbClient().deleteTable(r -> r.tableName(staticTableName)); - } - } - private void verifyDDBError(RecordForUpdateExpressions record, boolean ignoreNulls) { assertThatThrownBy(() -> mappedTable.updateItem(r -> r.item(record).ignoreNulls(ignoreNulls))) .isInstanceOf(DynamoDbException.class) @@ -968,42 +978,39 @@ private void putInitialItemAndVerify(RecordForUpdateExpressions record) { assertThat(requestAttributeList).hasSize(2).isEqualTo(REQUEST_ATTRIBUTES); } - private UpdateExpression expressionWithSetListElement(int index, String value) { - String listAttributeName = "requestAttributeList"; - String uniqueValueRef = ":val_" + value.replaceAll("[^a-zA-Z0-9]", "_"); - AttributeValue listElementValue = AttributeValue.builder().s(value).build(); - SetAction setListElement = SetAction.builder() - .path(keyRef(listAttributeName) + "[" + index + "]") - .value(uniqueValueRef) - .putExpressionValue(uniqueValueRef, listElementValue) - .putExpressionName(keyRef(listAttributeName), listAttributeName) - .build(); - return UpdateExpression.builder().addAction(setListElement).build(); - } - - private UpdateExpression expressionWithSetNumberAttribute(String attributeName, long value) { - String valueRef = ":value_" + value; - SetAction setAction = SetAction.builder() - .path(attributeName) - .value(valueRef) - .putExpressionValue(valueRef, AttributeValue.builder().n(Long.toString(value)).build()) - .build(); - return UpdateExpression.builder().addAction(setAction).build(); + /** + * 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(); } - private UpdateExpression expressionWithSetStringPath(String path, String value) { - String valueRef = ":str_" + value.replaceAll("[^a-zA-Z0-9]", "_"); - // Tokenize every path segment so reserved words (for example "name") are safe in UpdateExpression paths. - String tokenizedPath = path.replaceAll("([A-Za-z_][A-Za-z0-9_]*)", "#$1"); - Map expressionNames = Arrays.stream(path.replaceAll("\\[[0-9]+\\]", "").split("\\.")) - .collect(Collectors.toMap(segment -> "#" + segment, segment -> segment)); - SetAction setAction = SetAction.builder() - .path(tokenizedPath) - .value(valueRef) - .expressionNames(expressionNames) - .putExpressionValue(valueRef, AttributeValue.builder().s(value).build()) - .build(); - return UpdateExpression.builder().addAction(setAction).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 { @@ -1043,7 +1050,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())); } @@ -1076,33 +1083,34 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex } private UpdateExpression expressionWithSetFirstElement() { - return UpdateExpression.builder() - .addAction(SetAction.builder() - .path("requestAttributeList[0]") - .value(":extensionValue") - .putExpressionValue(":extensionValue", - AttributeValue.builder().s("extension0").build()) - .build()) - .build(); + 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") - .putExpressionValue(":extensionObject0", - AttributeValue.builder() - .s("extensionObject0") - .build()) - .build()) - .build()) - .build(); + return WriteModification + .builder() + .updateExpression( + UpdateExpression + .builder() + .addAction( + SetAction.builder() + .path("objectListAttribute[0].name") + .value(":extensionObject0") + .putExpressionValue(":extensionObject0", + AttributeValue.builder().s("extensionObject0").build()) + .build()) + .build()) + .build(); } } 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 6f4fd861d2f0..000000000000 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/RecordForUpdateExpressions.java +++ /dev/null @@ -1,85 +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.List; -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 List requestAttributeList; - private Long extensionAttribute1; - private Set extensionAttribute2; - private Long incrementedAttribute; - - @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 stringRequestAttribute) { - this.requestAttributeList = stringRequestAttribute; - } - - 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; - } -} 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 855cc15c5547..adf8d0aa40ae 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 @@ -53,7 +53,7 @@ public class UpdateItemOperationTransactTest { private DynamoDbEnhancedClientExtension mockDynamoDbEnhancedClientExtension; @Test - public void generateTransactWriteItem_wrapsGeneratedUpdateItemRequestInTransactUpdate() { + public void generateTransactWriteItem_basicRequest() { FakeItem fakeItem = createUniqueFakeItem(); Map fakeItemMap = FakeItem.getTableSchema().itemToMap(fakeItem, true); UpdateItemOperation updateItemOperation = @@ -89,7 +89,7 @@ public void generateTransactWriteItem_wrapsGeneratedUpdateItemRequestInTransactU } @Test - public void generateTransactWriteItem_includesConditionExpressionFromGeneratedRequest() { + public void generateTransactWriteItem_conditionalRequest() { FakeItem fakeItem = createUniqueFakeItem(); Map fakeItemMap = FakeItem.getTableSchema().itemToMap(fakeItem, true); UpdateItemOperation updateItemOperation = @@ -128,7 +128,7 @@ public void generateTransactWriteItem_includesConditionExpressionFromGeneratedRe } @Test - public void generateTransactWriteItem_propagatesReturnValuesOnConditionCheckFailure() { + public void generateTransactWriteItem_returnValuesOnConditionCheckFailure_generatesCorrectRequest() { FakeItem fakeItem = createUniqueFakeItem(); Map fakeItemMap = FakeItem.getTableSchema().itemToMap(fakeItem, true); String returnValues = "return-values"; @@ -158,7 +158,7 @@ public void generateTransactWriteItem_propagatesReturnValuesOnConditionCheckFail } @Test - public void generateRequest_transactUpdateWithSetExpression_emitsSameUpdateExpressionOnTransactWriteItem() { + public void generateTransactWriteItem_withSetAction_includesSetUpdateExpression() { FakeItem fakeItem = createUniqueFakeItem(); UpdateExpression requestExpression = UpdateExpression.builder() @@ -182,16 +182,14 @@ public void generateRequest_transactUpdateWithSetExpression_emitsSameUpdateExpre context, mockDynamoDbEnhancedClientExtension); - TransactWriteItem transactWriteItem = updateItemOperation.generateTransactWriteItem(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")); } - private UpdateItemRequest ddbRequest(Map keys, Consumer modify) { UpdateItemRequest.Builder builder = ddbBaseRequestBuilder(keys); modify.accept(builder); 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 index afbf788133f9..26197aa52dc7 100644 --- 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 @@ -17,14 +17,16 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertNull; -import static org.mockito.Mockito.mock; +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; @@ -32,14 +34,29 @@ 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 final TableMetadata mockTableMetadata = mock(TableMetadata.class); + private static final TableMetadata TABLE_METADATA = StaticTableMetadata.builder().build(); + + // -------------------------------------------------------------- + // LEGACY — default merge strategy (order map, extension, request) + // -------------------------------------------------------------- @Test - public void resolve_emptyInputs_returnsEmptyUpdateExpression() { + public void resolve_legacy_emptyInputs_returnsNull() { UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(Collections.emptyMap()) .build(); @@ -49,13 +66,13 @@ public void resolve_emptyInputs_returnsEmptyUpdateExpression() { } @Test - public void resolve_nonNullAttributes_generatesSetActions() { + public void resolve_legacy_nonNullAttributes_generatesSetActions() { Map itemMap = new HashMap<>(); - itemMap.put("itemAttr1Name", AttributeValue.builder().s("itemAttr1Value").build()); - itemMap.put("itemAttr2Name", AttributeValue.builder().n("itemAttr2Value").build()); + itemMap.put("attr1Name", AttributeValue.builder().s("attr1Value").build()); + itemMap.put("attr2Name", AttributeValue.builder().n("attr2Value").build()); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .build(); @@ -68,28 +85,28 @@ public void resolve_nonNullAttributes_generatesSetActions() { assertThat(result.setActions()).hasSize(2).containsExactlyInAnyOrder( SetAction.builder() - .path("#AMZN_MAPPED_itemAttr1Name") - .value(":AMZN_MAPPED_itemAttr1Name") - .putExpressionName("#AMZN_MAPPED_itemAttr1Name", "itemAttr1Name") - .putExpressionValue(":AMZN_MAPPED_itemAttr1Name", AttributeValue.builder().s("itemAttr1Value").build()) + .path("#AMZN_MAPPED_attr1Name") + .value(":AMZN_MAPPED_attr1Name") + .putExpressionName("#AMZN_MAPPED_attr1Name", "attr1Name") + .putExpressionValue(":AMZN_MAPPED_attr1Name", AttributeValue.builder().s("attr1Value").build()) .build(), SetAction.builder() - .path("#AMZN_MAPPED_itemAttr2Name") - .value(":AMZN_MAPPED_itemAttr2Name") - .putExpressionName("#AMZN_MAPPED_itemAttr2Name", "itemAttr2Name") - .putExpressionValue(":AMZN_MAPPED_itemAttr2Name", AttributeValue.builder().n("itemAttr2Value").build()) + .path("#AMZN_MAPPED_attr2Name") + .value(":AMZN_MAPPED_attr2Name") + .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") + .putExpressionValue(":AMZN_MAPPED_attr2Name", AttributeValue.builder().n("attr2Value").build()) .build()); } @Test - public void resolve_nullAttributes_generatesRemoveActions() { + public void resolve_legacy_nullAttributes_generatesRemoveActions() { Map itemMap = new HashMap<>(); - itemMap.put("itemAttr1Name", AttributeValue.builder().nul(true).build()); - itemMap.put("itemAttr2Name", AttributeValue.builder().nul(true).build()); + itemMap.put("attr1Name", AttributeValue.builder().nul(true).build()); + itemMap.put("attr2Name", AttributeValue.builder().nul(true).build()); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .build(); @@ -102,24 +119,24 @@ public void resolve_nullAttributes_generatesRemoveActions() { assertThat(result.removeActions()).hasSize(2).containsExactlyInAnyOrder( RemoveAction.builder() - .path("#AMZN_MAPPED_itemAttr1Name") - .putExpressionName("#AMZN_MAPPED_itemAttr1Name", "itemAttr1Name") + .path("#AMZN_MAPPED_attr1Name") + .putExpressionName("#AMZN_MAPPED_attr1Name", "attr1Name") .build(), RemoveAction.builder() - .path("#AMZN_MAPPED_itemAttr2Name") - .putExpressionName("#AMZN_MAPPED_itemAttr2Name", "itemAttr2Name") + .path("#AMZN_MAPPED_attr2Name") + .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") .build()); } @Test - public void resolve_mixedAttributes_generatesBothActions() { + public void resolve_legacy_mixedAttributes_generatesBothActions() { Map itemMap = new HashMap<>(); itemMap.put("setAttrName", AttributeValue.builder().s("setAttrValue").build()); itemMap.put("removeAttrName", AttributeValue.builder().nul(true).build()); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .build(); @@ -145,9 +162,9 @@ public void resolve_mixedAttributes_generatesBothActions() { } @Test - public void resolve_withItemAndExtensionExpression_mergesActions() { + public void resolve_legacy_withorderAndExtensionExpressions_mergesActions() { Map itemMap = new HashMap<>(); - itemMap.put("itemAttrName", AttributeValue.builder().s("itemAttrValue").build()); + itemMap.put("attrName", AttributeValue.builder().s("attrValue").build()); UpdateExpression extensionExpression = UpdateExpression.builder() @@ -160,7 +177,7 @@ public void resolve_withItemAndExtensionExpression_mergesActions() { .build(); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .extensionExpression(extensionExpression) .build(); @@ -173,10 +190,10 @@ public void resolve_withItemAndExtensionExpression_mergesActions() { assertThat(result.setActions()).isEqualTo(Collections.singletonList( SetAction.builder() - .path("#AMZN_MAPPED_itemAttrName") - .value(":AMZN_MAPPED_itemAttrName") - .putExpressionName("#AMZN_MAPPED_itemAttrName", "itemAttrName") - .putExpressionValue(":AMZN_MAPPED_itemAttrName", AttributeValue.builder().s("itemAttrValue").build()) + .path("#AMZN_MAPPED_attrName") + .value(":AMZN_MAPPED_attrName") + .putExpressionName("#AMZN_MAPPED_attrName", "attrName") + .putExpressionValue(":AMZN_MAPPED_attrName", AttributeValue.builder().s("attrValue").build()) .build())); assertThat(result.addActions()).isEqualTo(Collections.singletonList( @@ -188,32 +205,32 @@ public void resolve_withItemAndExtensionExpression_mergesActions() { } @Test - public void resolve_withAllExpressionTypes_mergesInCorrectOrder() { + public void resolve_legacy_withAllExpressionTypes_mergesActions() { Map itemMap = new HashMap<>(); - itemMap.put("itemAttrName", AttributeValue.builder().s("itemAttrValue").build()); - - UpdateExpression extensionExpression = - UpdateExpression.builder() - .addAction(AddAction.builder() - .path("extensionAttrName") - .value(":extensionAttrName") - .putExpressionValue(":extensionAttrName", AttributeValue.builder().s( - "extensionAttrValue").build()) - .build()) - .build(); - - UpdateExpression requestExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("requestAttrName") - .value(":requestAttrName") - .putExpressionValue(":requestAttrName", AttributeValue.builder().s( - "requestAttrValue").build()) - .build()) - .build(); + itemMap.put("attrName", AttributeValue.builder().s("attrValue").build()); + + UpdateExpression extensionExpression = UpdateExpression + .builder() + .addAction(AddAction.builder() + .path("extensionAttrName") + .value(":extensionAttrName") + .putExpressionValue(":extensionAttrName", + AttributeValue.builder().s("extensionAttrValue").build()) + .build()) + .build(); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("requestAttrName") + .value(":requestAttrName") + .putExpressionValue(":requestAttrName", + AttributeValue.builder().s("requestAttrValue").build()) + .build()) + .build(); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .extensionExpression(extensionExpression) .requestExpression(requestExpression) @@ -227,10 +244,10 @@ public void resolve_withAllExpressionTypes_mergesInCorrectOrder() { assertThat(result.setActions()).hasSize(2).containsExactlyInAnyOrder( SetAction.builder() - .path("#AMZN_MAPPED_itemAttrName") - .value(":AMZN_MAPPED_itemAttrName") - .putExpressionName("#AMZN_MAPPED_itemAttrName", "itemAttrName") - .putExpressionValue(":AMZN_MAPPED_itemAttrName", AttributeValue.builder().s("itemAttrValue").build()) + .path("#AMZN_MAPPED_attrName") + .value(":AMZN_MAPPED_attrName") + .putExpressionName("#AMZN_MAPPED_attrName", "attrName") + .putExpressionValue(":AMZN_MAPPED_attrName", AttributeValue.builder().s("attrValue").build()) .build(), SetAction.builder() .path("requestAttrName") @@ -247,24 +264,23 @@ public void resolve_withAllExpressionTypes_mergesInCorrectOrder() { } @Test - public void resolve_attributeUsedInOtherExpression_filteredOutFromRemoveActions() { + public void resolve_legacy_attributeUsedInOtherExpression_filteredOutFromRemoveActions() { Map itemMap = new HashMap<>(); - itemMap.put("itemAttr1Name", AttributeValue.builder().nul(true).build()); - itemMap.put("itemAttr2Name", AttributeValue.builder().nul(true).build()); - - UpdateExpression requestExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("itemAttr1Name") - .value(":itemAttr1Value") - .putExpressionName("#itemAttr1Name", "itemAttr1Name") - .putExpressionValue(":itemAttr1Value", AttributeValue.builder().s( - "itemAttr1Value_new").build()) - .build()) - .build(); + itemMap.put("attr1Name", AttributeValue.builder().nul(true).build()); + itemMap.put("attr2Name", AttributeValue.builder().nul(true).build()); + + UpdateExpression requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr1Name") + .value(":attr1Value") + .putExpressionName("#attr1Name", "attr1Name") + .putExpressionValue(":attr1Value", AttributeValue.builder().s("attr1Value_new").build()) + .build()) + .build(); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .requestExpression(requestExpression) .build(); @@ -277,53 +293,57 @@ public void resolve_attributeUsedInOtherExpression_filteredOutFromRemoveActions( assertThat(result.setActions()).isEqualTo(Collections.singletonList( SetAction.builder() - .path("itemAttr1Name") - .value(":itemAttr1Value") - .putExpressionName("#itemAttr1Name", "itemAttr1Name") - .putExpressionValue(":itemAttr1Value", AttributeValue.builder().s("itemAttr1Value_new").build()) + .path("attr1Name") + .value(":attr1Value") + .putExpressionName("#attr1Name", "attr1Name") + .putExpressionValue(":attr1Value", AttributeValue.builder().s("attr1Value_new").build()) .build())); - // only itemAttr2Name, itemAttr1Name filtered out (because was present in a set expression) + // only attr2Name, attr1Name filtered out (because was present in a set expression) assertThat(result.removeActions()).isEqualTo(Collections.singletonList( RemoveAction.builder() - .path("#AMZN_MAPPED_itemAttr2Name") - .putExpressionName("#AMZN_MAPPED_itemAttr2Name", "itemAttr2Name") + .path("#AMZN_MAPPED_attr2Name") + .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") .build())); } @Test - public void builder_allFields_buildsSuccessfully() { + 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .extensionExpression(extensionExpr) .requestExpression(requestExpr) .build(); - assertThat(resolver).isNotNull(); + 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); } - // ------------------------------------------------------- - // Null-safety: nonKeyAttributes not explicitly set - // ------------------------------------------------------- + // --------------------------------------------------------- + // LEGACY — no non-key order map / no request expression (extension only) + // --------------------------------------------------------- @Test - public void resolve_legacy_defaultBuilderWithoutNonKeyAttributes_returnsExtensionExpression() { - UpdateExpression extensionExpression = - UpdateExpression.builder() - .addAction(AddAction.builder() - .path("counter") - .value(":inc") - .putExpressionValue(":inc", AttributeValue.builder().n("1").build()) - .build()) - .build(); + 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .build() .resolve(); @@ -332,7 +352,7 @@ public void resolve_legacy_defaultBuilderWithoutNonKeyAttributes_returnsExtensio } // ------------------------------------------------------- - // LEGACY mode: overlapping paths are concatenated + // LEGACY — overlapping paths are concatenated // ------------------------------------------------------- @Test @@ -350,7 +370,7 @@ public void resolve_legacy_overlappingPojoAndRequest_concatenatesBothActions() { .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .requestExpression(requestExpression) .build() @@ -391,7 +411,7 @@ public void resolve_legacy_overlappingExtensionAndRequest_concatenatesBothAction .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) .build() @@ -410,40 +430,80 @@ public void resolve_legacy_overlappingExtensionAndRequest_concatenatesBothAction .build()); } - // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: three-source list overlap - // ------------------------------------------------------- - @Test - public void resolve_prioritizeHigherSource_listTouchedByPojoExtensionAndRequest_keepsRequestSetActionsOnly() { - Map itemMap = new HashMap<>(); - itemMap.put("list", AttributeValue.builder().l(AttributeValue.builder().s("pojo").build()).build()); - + public void resolve_legacy_mergeStrategyNull_defaultsToConcatenation() { UpdateExpression extensionExpression = UpdateExpression.builder() .addAction(SetAction.builder() - .path("list[0]") - .value(":extensionValue") - .putExpressionValue(":extensionValue", AttributeValue.builder().s("ext").build()) + .path("counter") + .value(":ext") + .putExpressionValue(":ext", AttributeValue.builder().n("10").build()) .build()) .build(); UpdateExpression requestExpression = UpdateExpression.builder() .addAction(SetAction.builder() - .path("list[1]") - .value(":requestValue") - .putExpressionValue(":requestValue", AttributeValue.builder().s("req").build()) + .path("counter") + .value(":req") + .putExpressionValue(":req", AttributeValue.builder().n("20").build()) .build()) .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .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_keepsRequestSetActionsOnly() { + 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) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); @@ -451,12 +511,12 @@ public void resolve_prioritizeHigherSource_listTouchedByPojoExtensionAndRequest_ SetAction.builder() .path("list[1]") .value(":requestValue") - .putExpressionValue(":requestValue", AttributeValue.builder().s("req").build()) + .putExpressionValue(":requestValue", AttributeValue.builder().s("req-value").build()) .build()); } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: sibling list indices (same top-level name) + // PRIORITIZE_HIGHER_SOURCE — sibling list indices (same top-level name) // ------------------------------------------------------- @Test @@ -469,6 +529,7 @@ public void resolve_prioritizeHigherSource_siblingListIndicesUnderSameAttribute_ .putExpressionValue(":v0", AttributeValue.builder().s("v0").build()) .build()) .build(); + UpdateExpression requestExpression = UpdateExpression.builder() .addAction(SetAction.builder() @@ -479,11 +540,10 @@ public void resolve_prioritizeHigherSource_siblingListIndicesUnderSameAttribute_ .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); @@ -496,7 +556,7 @@ public void resolve_prioritizeHigherSource_siblingListIndicesUnderSameAttribute_ } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: exact same scalar path + // PRIORITIZE_HIGHER_SOURCE — exact same scalar path // ------------------------------------------------------- @Test @@ -509,6 +569,7 @@ public void resolve_prioritizeHigherSource_identicalPathFromThreeSources_keepsRe .putExpressionValue(":ext", AttributeValue.builder().n("10").build()) .build()) .build(); + UpdateExpression requestExpression = UpdateExpression.builder() .addAction(SetAction.builder() @@ -519,11 +580,10 @@ public void resolve_prioritizeHigherSource_identicalPathFromThreeSources_keepsRe .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); @@ -536,89 +596,87 @@ public void resolve_prioritizeHigherSource_identicalPathFromThreeSources_keepsRe } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: object parent/child overlap + // PRIORITIZE_HIGHER_SOURCE — object parent/child overlap // ------------------------------------------------------- @Test public void resolve_prioritizeHigherSource_objectRootVersusNestedRequestPath_keepsRequestNestedSetOnly() { - UpdateExpression extensionExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("profile") - .value(":profile") - .putExpressionValue(":profile", - AttributeValue.builder().m(Collections.emptyMap()).build()) - .build()) - .build(); - UpdateExpression requestExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("profile.name") - .value(":name") - .putExpressionValue(":name", AttributeValue.builder().s("alice").build()) - .build()) - .build(); + 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); assertThat(result.setActions()).containsExactly( SetAction.builder() - .path("profile.name") + .path("customer.name") .value(":name") - .putExpressionValue(":name", AttributeValue.builder().s("alice").build()) + .putExpressionValue(":name", AttributeValue.builder().s("john").build()) .build()); } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: list-of-objects parent/child + // PRIORITIZE_HIGHER_SOURCE — list-of-objects parent/child // ------------------------------------------------------- @Test public void resolve_prioritizeHigherSource_objectListRootVersusNestedRequestPath_keepsRequestNestedSetOnly() { - UpdateExpression extensionExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("items[0]") - .value(":item0") - .putExpressionValue(":item0", - AttributeValue.builder().m(Collections.emptyMap()).build()) - .build()) - .build(); - UpdateExpression requestExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("items[0].name") - .value(":name") - .putExpressionValue(":name", AttributeValue.builder().s("new-name").build()) - .build()) - .build(); + 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); assertThat(result.setActions()).containsExactly( SetAction.builder() - .path("items[0].name") - .value(":name") - .putExpressionValue(":name", AttributeValue.builder().s("new-name").build()) + .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 + // PRIORITIZE_HIGHER_SOURCE — nested object paths under same top-level name // ------------------------------------------------------- @Test @@ -626,43 +684,43 @@ public void resolve_prioritizeHigherSource_objectNestedSiblingsUnderSameTopLevel UpdateExpression extensionExpression = UpdateExpression.builder() .addAction(SetAction.builder() - .path("profile.name") + .path("customer.name") .value(":name") - .putExpressionValue(":name", AttributeValue.builder().s("alice").build()) + .putExpressionValue(":name", AttributeValue.builder().s("john").build()) .build()) .build(); + UpdateExpression requestExpression = UpdateExpression.builder() .addAction(SetAction.builder() - .path("profile.address.city") + .path("customer.address.city") .value(":city") - .putExpressionValue(":city", AttributeValue.builder().s("seattle").build()) + .putExpressionValue(":city", AttributeValue.builder().s("london").build()) .build()) .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); assertThat(result.setActions()).containsExactly( SetAction.builder() - .path("profile.address.city") + .path("customer.address.city") .value(":city") - .putExpressionValue(":city", AttributeValue.builder().s("seattle").build()) + .putExpressionValue(":city", AttributeValue.builder().s("london").build()) .build()); } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: POJO-extension only (no request) + // PRIORITIZE_HIGHER_SOURCE — POJO-extension only (no request) // ------------------------------------------------------- @Test - public void resolve_prioritizeHigherSource_pojoAndExtensionShareAttributeWithoutRequest_keepsExtensionActionsOnly() { + public void resolve_prioritizeHigherSource_pojoAndExtensionShareAttributeWithoutRequest_keepsExtensionActionOnly() { Map itemMap = new HashMap<>(); itemMap.put("counter", AttributeValue.builder().n("10").build()); @@ -676,11 +734,10 @@ public void resolve_prioritizeHigherSource_pojoAndExtensionShareAttributeWithout .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .extensionExpression(extensionExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); @@ -693,34 +750,34 @@ public void resolve_prioritizeHigherSource_pojoAndExtensionShareAttributeWithout } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: extension-request only (no POJO) + // PRIORITIZE_HIGHER_SOURCE — extension-request only (no POJO) // ------------------------------------------------------- @Test - public void resolve_prioritizeHigherSource_extensionAndRequestShareAttributeWithoutPojo_keepsRequestActionsOnly() { - 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(); + 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); @@ -733,90 +790,89 @@ public void resolve_prioritizeHigherSource_extensionAndRequestShareAttributeWith } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: completely disjoint sources + // PRIORITIZE_HIGHER_SOURCE — completely disjoint sources // ------------------------------------------------------- @Test public void resolve_prioritizeHigherSource_allSourcesDisjointPaths_keepsEveryAction() { Map itemMap = new HashMap<>(); - itemMap.put("attrA", AttributeValue.builder().s("a").build()); - - UpdateExpression extensionExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("attrB") - .value(":b") - .putExpressionValue(":b", AttributeValue.builder().s("b").build()) - .build()) - .build(); - UpdateExpression requestExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("attrC") - .value(":c") - .putExpressionValue(":c", AttributeValue.builder().s("c").build()) - .build()) - .build(); + 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .extensionExpression(extensionExpression) .requestExpression(requestExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); - assertThat(result.setActions()).containsExactly( + assertThat(result.setActions()).containsExactlyInAnyOrder( SetAction.builder() - .path("attrC") - .value(":c") - .putExpressionValue(":c", AttributeValue.builder().s("c").build()) + .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("attrB") - .value(":b") - .putExpressionValue(":b", AttributeValue.builder().s("b").build()) + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", AttributeValue.builder().s("2").build()) .build(), SetAction.builder() - .path("#AMZN_MAPPED_attrA") - .value(":AMZN_MAPPED_attrA") - .putExpressionName("#AMZN_MAPPED_attrA", "attrA") - .putExpressionValue(":AMZN_MAPPED_attrA", AttributeValue.builder().s("a").build()) + .path("reqAttr") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("3").build()) .build()); } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: POJO REMOVE vs request SET + // PRIORITIZE_HIGHER_SOURCE — POJO REMOVE vs request SET // ------------------------------------------------------- @Test public void resolve_prioritizeHigherSource_pojoRemoveVsRequestSet_keepsRequestSetOnly() { Map itemMap = new HashMap<>(); - itemMap.put("attrX", AttributeValue.builder().nul(true).build()); + itemMap.put("attr", AttributeValue.builder().nul(true).build()); - UpdateExpression requestExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("attrX") - .value(":val") - .putExpressionValue(":val", AttributeValue.builder().s("new-value").build()) - .build()) - .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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .requestExpression(requestExpression) - .updateExpressionMergeStrategy( - PRIORITIZE_HIGHER_SOURCE) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); assertThat(result.setActions()).containsExactly( SetAction.builder() - .path("attrX") + .path("attr") .value(":val") .putExpressionValue(":val", AttributeValue.builder().s("new-value").build()) .build()); @@ -824,7 +880,7 @@ public void resolve_prioritizeHigherSource_pojoRemoveVsRequestSet_keepsRequestSe } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: three-source exact same path + // PRIORITIZE_HIGHER_SOURCE — three-source exact same path // ------------------------------------------------------- @Test @@ -832,25 +888,26 @@ 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 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .extensionExpression(extensionExpression) .requestExpression(requestExpression) @@ -867,20 +924,21 @@ public void resolve_prioritizeHigherSource_threeSourcesSamePath_keepsRequestOnly } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: expression attribute names (#list → list) + // PRIORITIZE_HIGHER_SOURCE — expression attribute names (#list → list) // ------------------------------------------------------- @Test public void resolve_prioritizeHigherSource_expressionNamePlaceholder_groupsByResolvedTopLevelName() { - 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 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() @@ -891,7 +949,7 @@ public void resolve_prioritizeHigherSource_expressionNamePlaceholder_groupsByRes .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) @@ -907,35 +965,36 @@ public void resolve_prioritizeHigherSource_expressionNamePlaceholder_groupsByRes } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: multiple request actions on one top-level name + // 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 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) @@ -956,30 +1015,31 @@ public void resolve_prioritizeHigherSource_requestMultipleActionsOnSameTopLevelN } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: extension vs request on different nested paths (same first segment) + // PRIORITIZE_HIGHER_SOURCE — extension vs request on different nested paths (same first segment) // ------------------------------------------------------- @Test public void resolve_prioritizeHigherSource_nestedPathsSameFirstSegment_keepsRequestOnly() { - 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 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) @@ -995,13 +1055,13 @@ public void resolve_prioritizeHigherSource_nestedPathsSameFirstSegment_keepsRequ } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: all sources null → returns null + // PRIORITIZE_HIGHER_SOURCE — all sources null → returns null // ------------------------------------------------------- @Test public void resolve_prioritizeHigherSource_allSourcesNull_returnsNull() { UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); @@ -1009,22 +1069,22 @@ public void resolve_prioritizeHigherSource_allSourcesNull_returnsNull() { } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: request only (no extension, no POJO) + // 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 requestExpression = UpdateExpression + .builder() + .addAction(SetAction.builder() + .path("attr") + .value(":val") + .putExpressionValue(":val", AttributeValue.builder().s("v").build()) + .build()) + .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .requestExpression(requestExpression) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() @@ -1038,8 +1098,30 @@ public void resolve_prioritizeHigherSource_requestOnlyNoOtherSources_returnsRequ .build()); } + @Test + public void resolve_prioritizeHigherSource_ownedAttributesButNoResolvedPathMatches_returnsNull() { + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(SetAction.builder() + .path("#missing") + .value(":v") + .putExpressionName("#other", "logicalAttr") + .putExpressionValue(":v", AttributeValue.builder().s("value").build()) + .build()) + .build(); + + UpdateExpression result = UpdateExpressionResolver.builder() + .tableMetadata(TABLE_METADATA) + .extensionExpression(extensionExpression) + .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) + .build() + .resolve(); + + assertNull(result); + } + // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: DELETE action from extension vs SET from request on same attribute + // PRIORITIZE_HIGHER_SOURCE — DELETE action from extension vs SET from request on same attribute // ------------------------------------------------------- @Test @@ -1050,9 +1132,9 @@ public void resolve_prioritizeHigherSource_extensionDeleteVsRequestSet_keepsRequ .path("tags") .value(":toRemove") .putExpressionValue(":toRemove", - AttributeValue.builder() - .ss("old-tag") - .build()) + AttributeValue.builder() + .ss("old-tag") + .build()) .build()) .build(); UpdateExpression requestExpression = @@ -1068,7 +1150,7 @@ public void resolve_prioritizeHigherSource_extensionDeleteVsRequestSet_keepsRequ .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) @@ -1085,7 +1167,7 @@ public void resolve_prioritizeHigherSource_extensionDeleteVsRequestSet_keepsRequ } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: extension ADD vs request SET on same attribute + // PRIORITIZE_HIGHER_SOURCE — extension ADD vs request SET on same attribute // ------------------------------------------------------- @Test @@ -1108,7 +1190,7 @@ public void resolve_prioritizeHigherSource_extensionAddVsRequestSet_keepsRequest .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .extensionExpression(extensionExpression) .requestExpression(requestExpression) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) @@ -1125,7 +1207,7 @@ public void resolve_prioritizeHigherSource_extensionAddVsRequestSet_keepsRequest } // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: POJO REMOVE vs extension SET (no request) + // PRIORITIZE_HIGHER_SOURCE — POJO REMOVE vs extension SET (no request) // ------------------------------------------------------- @Test @@ -1143,7 +1225,7 @@ public void resolve_prioritizeHigherSource_pojoRemoveVsExtensionSet_keepsExtensi .build(); UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(mockTableMetadata) + .tableMetadata(TABLE_METADATA) .nonKeyAttributes(itemMap) .extensionExpression(extensionExpression) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) @@ -1159,36 +1241,129 @@ public void resolve_prioritizeHigherSource_pojoRemoveVsExtensionSet_keepsExtensi assertThat(result.removeActions()).isEmpty(); } - // ------------------------------------------------------- - // PRIORITIZE_HIGHER_SOURCE: partial ownership — request owns one attr, extension owns another - // ------------------------------------------------------- + /* + * 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: one SET per name; request beats extension beats item. + * Same data as the LEGACY test above (3 actions). + * Winners: attr1 & attr2 → request; attr3 → item only. + */ @Test - public void resolve_prioritizeHigherSource_partialOwnership_eachSourceKeepsOwnedAttributes() { - UpdateExpression extensionExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("counter") - .value(":extC") - .putExpressionValue(":extC", AttributeValue.builder().n("10").build()) - .build()) - .addAction(SetAction.builder() - .path("status") - .value(":extS") - .putExpressionValue(":extS", AttributeValue.builder().s("ext-status").build()) - .build()) - .build(); - UpdateExpression requestExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("counter") - .value(":req") - .putExpressionValue(":req", AttributeValue.builder().n("99").build()) - .build()) - .build(); + 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(mockTableMetadata) + .tableMetadata(TABLE_METADATA) + .nonKeyAttributes(itemMap) .extensionExpression(extensionExpression) .requestExpression(requestExpression) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) @@ -1197,14 +1372,20 @@ public void resolve_prioritizeHigherSource_partialOwnership_eachSourceKeepsOwned assertThat(result.setActions()).containsExactlyInAnyOrder( SetAction.builder() - .path("counter") - .value(":req") - .putExpressionValue(":req", AttributeValue.builder().n("99").build()) + .path("attr1") + .value(":r1") + .putExpressionValue(":r1", AttributeValue.builder().s("value1_req").build()) .build(), SetAction.builder() - .path("status") - .value(":extS") - .putExpressionValue(":extS", AttributeValue.builder().s("ext-status").build()) + .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()); } } \ 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 index 88499df33640..0df10582a942 100644 --- 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 @@ -15,23 +15,26 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.update; +import static java.util.Collections.singleton; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +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 final TableMetadata mockTableMetadata = mock(TableMetadata.class); + private static final TableMetadata TABLE_METADATA = StaticTableMetadata.builder().build(); @Test public void ifNotExists_mapsKeyAndValueToIfNotExistsExpression() { @@ -42,7 +45,7 @@ public void ifNotExists_mapsKeyAndValueToIfNotExistsExpression() { @Test public void setActionsFor_emptyMap_returnsEmptyList() { - List result = UpdateExpressionUtils.setActionsFor(Collections.emptyMap(), mockTableMetadata); + List result = UpdateExpressionUtils.setActionsFor(Collections.emptyMap(), TABLE_METADATA); assertThat(result).isEmpty(); } @@ -51,9 +54,8 @@ public void setActionsFor_emptyMap_returnsEmptyList() { public void setActionsFor_singleAttribute_createsSetAction() { Map attributes = new HashMap<>(); attributes.put("attrName", AttributeValue.builder().s("attrValue").build()); - when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); - List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); assertThat(result).isEqualTo(Collections.singletonList( SetAction.builder() @@ -69,9 +71,8 @@ public void setActionsFor_multipleAttributes_createsMultipleSetActions() { Map attributes = new HashMap<>(); attributes.put("attr1Name", AttributeValue.builder().s("attr1Value").build()); attributes.put("attr2Name", AttributeValue.builder().n("attr2Value").build()); - when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); - List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); assertThat(result).hasSize(2).containsExactlyInAnyOrder( SetAction.builder() @@ -93,9 +94,8 @@ public void setActionsFor_multipleAttributes_createsMultipleSetActions() { public void setActionsFor_twoLevelDottedPath_producesSingleMappedSetAction() { Map attributes = new HashMap<>(); attributes.put("level1.level2", AttributeValue.builder().s("attrValue").build()); - when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); - List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); assertThat(result).isEqualTo(Collections.singletonList( SetAction.builder() @@ -110,9 +110,8 @@ public void setActionsFor_twoLevelDottedPath_producesSingleMappedSetAction() { public void setActionsFor_threeLevelDottedPath_producesSingleMappedSetAction() { Map attributes = new HashMap<>(); attributes.put("level1.level2.level3", AttributeValue.builder().s("attrValue").build()); - when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); - List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); assertThat(result).isEqualTo(Collections.singletonList( SetAction.builder() @@ -128,9 +127,8 @@ public void setActionsFor_dashAndUnderscoreNames_producesDistinctMappedSetAction Map attributes = new HashMap<>(); attributes.put("attrWithDash", AttributeValue.builder().s("#value").build()); attributes.put("attrWithUnderscore", AttributeValue.builder().s("_value").build()); - when(mockTableMetadata.primaryKeys()).thenReturn(Collections.emptyList()); - List result = UpdateExpressionUtils.setActionsFor(attributes, mockTableMetadata); + List result = UpdateExpressionUtils.setActionsFor(attributes, TABLE_METADATA); assertThat(result).hasSize(2).containsExactlyInAnyOrder( SetAction.builder() @@ -235,5 +233,116 @@ public void removeActionsFor_dashAndUnderscoreNames_producesDistinctMappedRemove .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 requestExpression = + UpdateExpression.builder() + .addAction( + SetAction.builder() + .path("customer.name") + .value(":name") + .putExpressionValue(":name", AttributeValue.builder().s("john").build()) + .build()) + .build(); + + UpdateExpression extensionExpression = + UpdateExpression.builder() + .addAction(RemoveAction.builder().path("orders[0]").build()) + .build(); + + Set result = UpdateExpressionUtils.attributesPresentInOtherExpressions( + Arrays.asList(requestExpression, extensionExpression)); + + assertThat(result).containsExactlyInAnyOrder("customer", "orders"); + } + + @Test + public void generateItemSetExpression_excludesNullAttributes() { + Map itemMap = new HashMap<>(); + itemMap.put("setAttr", AttributeValue.builder().s("set-value").build()); + itemMap.put("removeAttr", AttributeValue.builder().nul(true).build()); + + UpdateExpression result = UpdateExpressionUtils.generateItemSetExpression(itemMap, TABLE_METADATA); + + assertThat(result.setActions()).containsExactly( + SetAction.builder() + .path("#AMZN_MAPPED_setAttr") + .value(":AMZN_MAPPED_setAttr") + .putExpressionName("#AMZN_MAPPED_setAttr", "setAttr") + .putExpressionValue(":AMZN_MAPPED_setAttr", AttributeValue.builder().s("set-value").build()) + .build()); + } + + @Test + public void generateItemRemoveExpression_skipsNonNullValuesAndExcludedAttributes() { + Map itemMap = new HashMap<>(); + itemMap.put("setAttr", AttributeValue.builder().s("set-value").build()); + itemMap.put("removeAttr", AttributeValue.builder().nul(true).build()); + itemMap.put("excludedFromRemovalAttr", AttributeValue.builder().nul(true).build()); + + UpdateExpression result = UpdateExpressionUtils.generateItemRemoveExpression(itemMap, singleton( + "excludedFromRemovalAttr")); + + assertThat(result.removeActions()).containsExactly( + RemoveAction.builder() + .path("#AMZN_MAPPED_removeAttr") + .putExpressionName("#AMZN_MAPPED_removeAttr", "removeAttr") + .build()); + } + + @Test + public void generateItemSetExpression_whenOnlyNullAttributes_returnsNoSetActions() { + Map itemMap = new HashMap<>(); + itemMap.put("removeAttr", 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("setAttr", AttributeValue.builder().s("set-value").build()); + + UpdateExpression result = UpdateExpressionUtils.generateItemRemoveExpression(itemMap, Collections.emptySet()); + + assertThat(result.removeActions()).isEmpty(); + } + + @Test + public void resolveTopLevelAttributeName_whenTokenizedNestedPath_returnsTopLevelName() { + Map expressionNames = new HashMap<>(); + expressionNames.put("#customer", "customer"); + expressionNames.put("#name", "name"); + + String result = UpdateExpressionUtils.resolveTopLevelAttributeName("#customer.#name[0]", expressionNames); + + assertThat(result).isEqualTo("customer"); + } + + @Test + public void resolveTopLevelAttributeName_whenLiteralPathWithListIndex_returnsTopLevelName() { + String result = UpdateExpressionUtils.resolveTopLevelAttributeName("orders[1]", null); + + assertThat(result).isEqualTo("orders"); + } } 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 1013595ab345..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 @@ -36,7 +36,7 @@ public class TransactUpdateItemEnhancedRequestTest { @Test - public void builder_minimal() { + public void emptyBuilder_optionalFieldsAreNull_mergeStrategyIsLegacy() { TransactUpdateItemEnhancedRequest builtObject = TransactUpdateItemEnhancedRequest.builder(FakeItem.class).build(); @@ -158,6 +158,8 @@ public void equals_conditionExpressionNotEqual() { .putExpressionValue(":value1", stringValue("three")) .build(); + + TransactUpdateItemEnhancedRequest builtObject1 = TransactUpdateItemEnhancedRequest.builder(FakeItem.class).conditionExpression(conditionExpression1).build(); @@ -287,7 +289,7 @@ public void builder_returnValuesOnConditionCheckFailureNewValue_stringGetter() { } @Test - public void toBuilder_roundTrip_preservesPrioritizeHigherSourceMergeStrategy() { + public void toBuilder_preservesPrioritizeHigherSourceMergeStrategy() { TransactUpdateItemEnhancedRequest request = TransactUpdateItemEnhancedRequest.builder(FakeItem.class) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) 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 2fa36f699995..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 @@ -38,7 +38,7 @@ public class UpdateItemEnhancedRequestTest { @Test - public void builder_defaults_optionalFieldsNull_mergeStrategyLegacy() { + public void emptyBuilder_optionalFieldsAreNull_mergeStrategyIsLegacy() { UpdateItemEnhancedRequest builtObject = UpdateItemEnhancedRequest.builder(FakeItem.class).build(); assertThat(builtObject.item(), is(nullValue())); @@ -88,7 +88,7 @@ public void builder_maximal() { } @Test - public void toBuilder_roundTrip_equalsOriginal() { + public void toBuilder() { FakeItem fakeItem = createUniqueFakeItem(); Expression conditionExpression = Expression.builder() @@ -309,7 +309,7 @@ public void hashCode_returnValuesOnConditionCheckFailure() { } @Test - public void toBuilder_roundTrip_preservesPrioritizeHigherSourceMergeStrategy() { + public void toBuilder_preservesPrioritizeHigherSourceMergeStrategy() { UpdateItemEnhancedRequest request = UpdateItemEnhancedRequest.builder(FakeItem.class) .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) From de8d0de71f3a693227afdd4af16e9c042d44d088 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Fri, 27 Mar 2026 10:43:42 +0200 Subject: [PATCH 6/9] Addressed PR feedback --- .../functionaltests/UpdateExpressionTest.java | 32 ++-- .../update/UpdateExpressionResolverTest.java | 147 ++++++++++-------- .../update/UpdateExpressionUtilsTest.java | 92 +++++------ 3 files changed, 138 insertions(+), 133 deletions(-) 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 57e30901c1e8..ef215b7f4435 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 @@ -24,8 +24,6 @@ import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification; 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; @@ -179,13 +177,10 @@ public void updateItem_givenPreservingExtension_attributeAbsentFromPojo_whenIgno } /** - * 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, ignoreNulls 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 updateItem_givenPreservingExtension_attributeAbsentFromPojo_whenIgnoreNullsFalse_thenRemoveSuppressedForExtensionPath() { @@ -381,8 +376,7 @@ public void updateItem_givenRequestExpressionSetsListIndex_whenIgnoreNullsTrue_t } /** - * Tests DynamoDbException is thrown when same attribute is referenced both in the POJO item and in an explicit - * UpdateExpression provided on the request + * 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() { @@ -408,11 +402,11 @@ public void updateItem_givenLegacyMergeStrategy_whenPojoAndRequestSetSameScalar_ mappedTable.putItem(record); record.setExtensionNumberAttribute(100L); - UpdateExpression requestExpression = updateExpressionSetLongAttribute("extensionNumberAttribute", 200L); + UpdateExpression reqExpression = updateExpressionSetLongAttribute("extensionNumberAttribute", 200L); assertThatThrownBy(() -> mappedTable.updateItem( r -> r.item(record) - .updateExpression(requestExpression) + .updateExpression(reqExpression) .updateExpressionMergeStrategy(LEGACY))) .isInstanceOf(DynamoDbException.class) .hasMessageContaining("Two document paths"); @@ -553,12 +547,12 @@ public void updateItem_givenLegacyMergeStrategy_whenExtensionAndRequestSetSameSc initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); - UpdateExpression conflictingExpression = + UpdateExpression reqExpression = updateExpressionSetLongAttribute("extensionNumberAttribute", 99L); assertThatThrownBy(() -> mappedTable.updateItem( r -> r.item(keyRecord) - .updateExpression(conflictingExpression) + .updateExpression(reqExpression) .updateExpressionMergeStrategy(LEGACY))) .isInstanceOf(DynamoDbException.class) .hasMessageContaining("Two document paths"); @@ -602,9 +596,9 @@ public void updateItem_givenPrioritizeHigherSourceMerge_whenDisjointTopLevelName @Test public void updateItem_givenDefaultMergeStrategy_whenExtensionAndRequestSetSameScalar_thenDynamoDbRejectsOverlap() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions recordForUpdateExpressions = createKeyOnlyRecord(); + RecordForUpdateExpressions keyRecord = createKeyOnlyRecord(); - UpdateExpression conflictingExpression = + UpdateExpression reqExpression = UpdateExpression.builder() .addAction( SetAction.builder() @@ -617,7 +611,7 @@ public void updateItem_givenDefaultMergeStrategy_whenExtensionAndRequestSetSameS .build(); assertThatThrownBy(() -> mappedTable.updateItem( - r -> r.item(recordForUpdateExpressions).updateExpression(conflictingExpression))) + r -> r.item(keyRecord).updateExpression(reqExpression))) .isInstanceOf(DynamoDbException.class) .hasMessageContaining("Two document paths") .hasMessageContaining(NUMBER_ATTRIBUTE_REF); @@ -633,7 +627,7 @@ public void updateItem_givenNoRequestExpression_whenPojoOnlyUpdate_thenScalarPer initClientWithExtensions(); RecordForUpdateExpressions record = createSimpleRecord(); - // This should work exactly as before - just POJO updates, no extensions or request expressions + // Backward-compatible baseline: POJO update flow without request-level expression. mappedTable.putItem(record); record.setExtensionNumberAttribute(100L); mappedTable.updateItem(r -> r.item(record)); @@ -650,7 +644,7 @@ public void updateItem_givenNoRequestExpression_whenExtensionOnlyUpdate_thenExte initClientWithExtensions(new ItemPreservingUpdateExtension()); RecordForUpdateExpressions record = createSimpleRecord(); - // This should work exactly as before - extension updates attribute not in POJO + // Backward-compatible baseline: extension-only mutation without request-level expression. mappedTable.putItem(record); mappedTable.updateItem(r -> r.item(record)); 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 index 26197aa52dc7..6da5fc89e7dc 100644 --- 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 @@ -68,8 +68,8 @@ public void resolve_legacy_emptyInputs_returnsNull() { @Test public void resolve_legacy_nonNullAttributes_generatesSetActions() { Map itemMap = new HashMap<>(); - itemMap.put("attr1Name", AttributeValue.builder().s("attr1Value").build()); - itemMap.put("attr2Name", AttributeValue.builder().n("attr2Value").build()); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + itemMap.put("attrTwo", AttributeValue.builder().n("2").build()); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() .tableMetadata(TABLE_METADATA) @@ -85,25 +85,25 @@ public void resolve_legacy_nonNullAttributes_generatesSetActions() { assertThat(result.setActions()).hasSize(2).containsExactlyInAnyOrder( SetAction.builder() - .path("#AMZN_MAPPED_attr1Name") - .value(":AMZN_MAPPED_attr1Name") - .putExpressionName("#AMZN_MAPPED_attr1Name", "attr1Name") - .putExpressionValue(":AMZN_MAPPED_attr1Name", AttributeValue.builder().s("attr1Value").build()) + .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_attr2Name") - .value(":AMZN_MAPPED_attr2Name") - .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") - .putExpressionValue(":AMZN_MAPPED_attr2Name", AttributeValue.builder().n("attr2Value").build()) + .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("attr1Name", AttributeValue.builder().nul(true).build()); - itemMap.put("attr2Name", AttributeValue.builder().nul(true).build()); + itemMap.put("attr", AttributeValue.builder().nul(true).build()); + itemMap.put("attrTwo", AttributeValue.builder().nul(true).build()); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() .tableMetadata(TABLE_METADATA) @@ -119,21 +119,21 @@ public void resolve_legacy_nullAttributes_generatesRemoveActions() { assertThat(result.removeActions()).hasSize(2).containsExactlyInAnyOrder( RemoveAction.builder() - .path("#AMZN_MAPPED_attr1Name") - .putExpressionName("#AMZN_MAPPED_attr1Name", "attr1Name") + .path("#AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") .build(), RemoveAction.builder() - .path("#AMZN_MAPPED_attr2Name") - .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") + .path("#AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") .build()); } @Test public void resolve_legacy_mixedAttributes_generatesBothActions() { Map itemMap = new HashMap<>(); - itemMap.put("setAttrName", AttributeValue.builder().s("setAttrValue").build()); - itemMap.put("removeAttrName", AttributeValue.builder().nul(true).build()); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); + itemMap.put("attrToRemove", AttributeValue.builder().nul(true).build()); UpdateExpressionResolver resolver = UpdateExpressionResolver.builder() .tableMetadata(TABLE_METADATA) @@ -148,30 +148,30 @@ public void resolve_legacy_mixedAttributes_generatesBothActions() { assertThat(result.setActions()).isEqualTo(Collections.singletonList( SetAction.builder() - .path("#AMZN_MAPPED_setAttrName") - .value(":AMZN_MAPPED_setAttrName") - .putExpressionName("#AMZN_MAPPED_setAttrName", "setAttrName") - .putExpressionValue(":AMZN_MAPPED_setAttrName", AttributeValue.builder().s("setAttrValue").build()) + .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_removeAttrName") - .putExpressionName("#AMZN_MAPPED_removeAttrName", "removeAttrName") + .path("#AMZN_MAPPED_attrToRemove") + .putExpressionName("#AMZN_MAPPED_attrToRemove", "attrToRemove") .build())); } @Test - public void resolve_legacy_withorderAndExtensionExpressions_mergesActions() { + public void resolve_legacy_withPojoAndExtensionExpressions_mergesActions() { Map itemMap = new HashMap<>(); - itemMap.put("attrName", AttributeValue.builder().s("attrValue").build()); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); UpdateExpression extensionExpression = UpdateExpression.builder() .addAction(AddAction.builder() - .path("extensionAttrName") - .value(":extensionAttrValue") - .putExpressionValue(":extensionAttrValue", + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", AttributeValue.builder().n("1").build()) .build()) .build(); @@ -190,42 +190,42 @@ public void resolve_legacy_withorderAndExtensionExpressions_mergesActions() { assertThat(result.setActions()).isEqualTo(Collections.singletonList( SetAction.builder() - .path("#AMZN_MAPPED_attrName") - .value(":AMZN_MAPPED_attrName") - .putExpressionName("#AMZN_MAPPED_attrName", "attrName") - .putExpressionValue(":AMZN_MAPPED_attrName", AttributeValue.builder().s("attrValue").build()) + .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("extensionAttrName") - .value(":extensionAttrValue") - .putExpressionValue(":extensionAttrValue", AttributeValue.builder().n("1").build()) + .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("attrName", AttributeValue.builder().s("attrValue").build()); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); UpdateExpression extensionExpression = UpdateExpression .builder() .addAction(AddAction.builder() - .path("extensionAttrName") - .value(":extensionAttrName") - .putExpressionValue(":extensionAttrName", - AttributeValue.builder().s("extensionAttrValue").build()) + .path("extAttr") + .value(":extAttrValue") + .putExpressionValue(":extAttrValue", + AttributeValue.builder().s("extAttrValue").build()) .build()) .build(); UpdateExpression requestExpression = UpdateExpression .builder() .addAction(SetAction.builder() - .path("requestAttrName") - .value(":requestAttrName") - .putExpressionValue(":requestAttrName", - AttributeValue.builder().s("requestAttrValue").build()) + .path("reqAttr") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", + AttributeValue.builder().s("reqAttrValue").build()) .build()) .build(); @@ -244,38 +244,38 @@ public void resolve_legacy_withAllExpressionTypes_mergesActions() { assertThat(result.setActions()).hasSize(2).containsExactlyInAnyOrder( SetAction.builder() - .path("#AMZN_MAPPED_attrName") - .value(":AMZN_MAPPED_attrName") - .putExpressionName("#AMZN_MAPPED_attrName", "attrName") - .putExpressionValue(":AMZN_MAPPED_attrName", AttributeValue.builder().s("attrValue").build()) + .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("requestAttrName") - .value(":requestAttrName") - .putExpressionValue(":requestAttrName", AttributeValue.builder().s("requestAttrValue").build()) + .path("reqAttr") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("reqAttrValue").build()) .build()); assertThat(result.addActions()).isEqualTo(Collections.singletonList( AddAction.builder() - .path("extensionAttrName") - .value(":extensionAttrName") - .putExpressionValue(":extensionAttrName", AttributeValue.builder().s("extensionAttrValue").build()) + .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("attr1Name", AttributeValue.builder().nul(true).build()); - itemMap.put("attr2Name", AttributeValue.builder().nul(true).build()); + itemMap.put("attr", AttributeValue.builder().nul(true).build()); + itemMap.put("attrTwo", AttributeValue.builder().nul(true).build()); UpdateExpression requestExpression = UpdateExpression .builder() .addAction(SetAction.builder() - .path("attr1Name") - .value(":attr1Value") - .putExpressionName("#attr1Name", "attr1Name") - .putExpressionValue(":attr1Value", AttributeValue.builder().s("attr1Value_new").build()) + .path("attr") + .value(":reqAttrValue") + .putExpressionName("#attr", "attr") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("reqAttrValue").build()) .build()) .build(); @@ -293,17 +293,17 @@ public void resolve_legacy_attributeUsedInOtherExpression_filteredOutFromRemoveA assertThat(result.setActions()).isEqualTo(Collections.singletonList( SetAction.builder() - .path("attr1Name") - .value(":attr1Value") - .putExpressionName("#attr1Name", "attr1Name") - .putExpressionValue(":attr1Value", AttributeValue.builder().s("attr1Value_new").build()) + .path("attr") + .value(":reqAttrValue") + .putExpressionName("#attr", "attr") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("reqAttrValue").build()) .build())); - // only attr2Name, attr1Name filtered out (because was present in a set expression) + // Only attrTwo remains for REMOVE because attr is referenced by request expression. assertThat(result.removeActions()).isEqualTo(Collections.singletonList( RemoveAction.builder() - .path("#AMZN_MAPPED_attr2Name") - .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") + .path("#AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") .build())); } @@ -327,6 +327,17 @@ public void resolve_legacy_builder_preservesConfiguredFields() { 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) // --------------------------------------------------------- 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 index 0df10582a942..72f3cb89988f 100644 --- 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 @@ -53,40 +53,40 @@ public void setActionsFor_emptyMap_returnsEmptyList() { @Test public void setActionsFor_singleAttribute_createsSetAction() { Map attributes = new HashMap<>(); - attributes.put("attrName", AttributeValue.builder().s("attrValue").build()); + 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_attrName") - .value(":AMZN_MAPPED_attrName") - .putExpressionName("#AMZN_MAPPED_attrName", "attrName") - .putExpressionValue(":AMZN_MAPPED_attrName", AttributeValue.builder().s("attrValue").build()) + .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("attr1Name", AttributeValue.builder().s("attr1Value").build()); - attributes.put("attr2Name", AttributeValue.builder().n("attr2Value").build()); + 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_attr1Name") - .value(":AMZN_MAPPED_attr1Name") - .putExpressionName("#AMZN_MAPPED_attr1Name", "attr1Name") - .putExpressionValue(":AMZN_MAPPED_attr1Name", AttributeValue.builder().s("attr1Value").build()) + .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_attr2Name") - .value(":AMZN_MAPPED_attr2Name") - .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") - .putExpressionValue(":AMZN_MAPPED_attr2Name", AttributeValue.builder().n("attr2Value").build()) + .path("#AMZN_MAPPED_attrTwo") + .value(":AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") + .putExpressionValue(":AMZN_MAPPED_attrTwo", AttributeValue.builder().n("2").build()) .build()); } @@ -156,33 +156,33 @@ public void removeActionsFor_emptyMap_returnsEmptyList() { @Test public void removeActionsFor_singleAttribute_createsRemoveAction() { Map attributes = new HashMap<>(); - attributes.put("attrName", AttributeValue.builder().s("attrValue").build()); + attributes.put("attr", AttributeValue.builder().s("attrValue").build()); List result = UpdateExpressionUtils.removeActionsFor(attributes); assertThat(result).isEqualTo(Collections.singletonList( RemoveAction.builder() - .path("#AMZN_MAPPED_attrName") - .putExpressionName("#AMZN_MAPPED_attrName", "attrName") + .path("#AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") .build())); } @Test public void removeActionsFor_multipleAttributes_createsMultipleRemoveActions() { Map attributes = new HashMap<>(); - attributes.put("attr1Name", AttributeValue.builder().nul(true).build()); - attributes.put("attr2Name", AttributeValue.builder().nul(true).build()); + 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_attr1Name") - .putExpressionName("#AMZN_MAPPED_attr1Name", "attr1Name") + .path("#AMZN_MAPPED_attr") + .putExpressionName("#AMZN_MAPPED_attr", "attr") .build(), RemoveAction.builder() - .path("#AMZN_MAPPED_attr2Name") - .putExpressionName("#AMZN_MAPPED_attr2Name", "attr2Name") + .path("#AMZN_MAPPED_attrTwo") + .putExpressionName("#AMZN_MAPPED_attrTwo", "attrTwo") .build()); } @@ -252,65 +252,65 @@ public void attributesPresentInOtherExpressions_skipsNullExpressions() { @Test public void attributesPresentInOtherExpressions_unionsTopLevelNamesFromEachExpression() { - UpdateExpression requestExpression = + UpdateExpression reqExpression = UpdateExpression.builder() .addAction( SetAction.builder() - .path("customer.name") - .value(":name") - .putExpressionValue(":name", AttributeValue.builder().s("john").build()) + .path("reqAttr.name") + .value(":reqAttrValue") + .putExpressionValue(":reqAttrValue", AttributeValue.builder().s("john").build()) .build()) .build(); - UpdateExpression extensionExpression = + UpdateExpression extExpression = UpdateExpression.builder() - .addAction(RemoveAction.builder().path("orders[0]").build()) + .addAction(RemoveAction.builder().path("extAttr[0]").build()) .build(); Set result = UpdateExpressionUtils.attributesPresentInOtherExpressions( - Arrays.asList(requestExpression, extensionExpression)); + Arrays.asList(reqExpression, extExpression)); - assertThat(result).containsExactlyInAnyOrder("customer", "orders"); + assertThat(result).containsExactlyInAnyOrder("reqAttr", "extAttr"); } @Test public void generateItemSetExpression_excludesNullAttributes() { Map itemMap = new HashMap<>(); - itemMap.put("setAttr", AttributeValue.builder().s("set-value").build()); - itemMap.put("removeAttr", AttributeValue.builder().nul(true).build()); + 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_setAttr") - .value(":AMZN_MAPPED_setAttr") - .putExpressionName("#AMZN_MAPPED_setAttr", "setAttr") - .putExpressionValue(":AMZN_MAPPED_setAttr", AttributeValue.builder().s("set-value").build()) + .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("setAttr", AttributeValue.builder().s("set-value").build()); - itemMap.put("removeAttr", AttributeValue.builder().nul(true).build()); - itemMap.put("excludedFromRemovalAttr", AttributeValue.builder().nul(true).build()); + 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( - "excludedFromRemovalAttr")); + "excludedAttr")); assertThat(result.removeActions()).containsExactly( RemoveAction.builder() - .path("#AMZN_MAPPED_removeAttr") - .putExpressionName("#AMZN_MAPPED_removeAttr", "removeAttr") + .path("#AMZN_MAPPED_attrToRemove") + .putExpressionName("#AMZN_MAPPED_attrToRemove", "attrToRemove") .build()); } @Test public void generateItemSetExpression_whenOnlyNullAttributes_returnsNoSetActions() { Map itemMap = new HashMap<>(); - itemMap.put("removeAttr", AttributeValue.builder().nul(true).build()); + itemMap.put("attr", AttributeValue.builder().nul(true).build()); UpdateExpression result = UpdateExpressionUtils.generateItemSetExpression(itemMap, TABLE_METADATA); @@ -320,7 +320,7 @@ public void generateItemSetExpression_whenOnlyNullAttributes_returnsNoSetActions @Test public void generateItemRemoveExpression_whenOnlyNonNullAttributes_returnsNoRemoveActions() { Map itemMap = new HashMap<>(); - itemMap.put("setAttr", AttributeValue.builder().s("set-value").build()); + itemMap.put("attr", AttributeValue.builder().s("attrValue").build()); UpdateExpression result = UpdateExpressionUtils.generateItemRemoveExpression(itemMap, Collections.emptySet()); From 130d25a621ad89532ce80bdb699539c927b18b88 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Thu, 2 Apr 2026 18:56:51 +0300 Subject: [PATCH 7/9] Addressed PR feedback --- .../operations/UpdateItemOperation.java | 2 +- .../update/UpdateExpressionConverter.java | 21 +- .../update/UpdateExpressionResolver.java | 258 ++++++++++-------- .../update/UpdateExpressionUtils.java | 17 +- .../model/UpdateExpressionMergeStrategy.java | 52 ++-- .../model/UpdateItemEnhancedRequest.java | 6 +- .../functionaltests/UpdateExpressionTest.java | 18 +- .../update/UpdateExpressionConverterTest.java | 9 +- .../update/UpdateExpressionResolverTest.java | 202 +++++++++++--- .../update/UpdateExpressionUtilsTest.java | 18 -- 10 files changed, 383 insertions(+), 220 deletions(-) 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 1a87ca27c062..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 @@ -273,7 +273,7 @@ public TransactWriteItem generateTransactWriteItem(TableSchema tableSchema, O /** * 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} (one winning source per top-level attribute name). + * {@link UpdateExpressionMergeStrategy} (path overlap resolution; request wins over extension over POJO). */ private Expression generateUpdateExpressionIfExist( TableMetadata tableMetadata, 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 a9a071131d5f..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,19 +109,7 @@ public static List findAttributeNames(UpdateExpression updateExpression) List attributeNames = listPathsWithoutTokens(updateExpression); List attributeNamesFromTokens = listAttributeNamesFromTokens(updateExpression); attributeNames.addAll(attributeNamesFromTokens); - return attributeNames; - } - - /** - * Returns the top-level segment of a DynamoDB update expression document path: the substring before the first - * {@code .} (nested map attribute) or {@code [} (list index). For example, {@code attr}, {@code attr[0]}, and - * {@code attr.nested} all share the same top-level name {@code attr}, which is the DynamoDB attribute used for grouping and - * overlap rules. - * - * @param attributeName a path or name segment after any {@code #} expression-name substitution; must not be {@code null} - */ - static String removeNestingAndListReference(String attributeName) { - return attributeName.substring(0, getRemovalIndex(attributeName)); + return attributeNames.stream().distinct().collect(Collectors.toList()); } private static List groupExpressions(UpdateExpression expression) { @@ -228,6 +216,9 @@ private static List listAttributeNamesFromTokens(UpdateExpression update .collect(Collectors.toList()); } + private static String removeNestingAndListReference(String attributeName) { + return attributeName.substring(0, getRemovalIndex(attributeName)); + } private static int getRemovalIndex(String attributeName) { for (int i = 0; i < attributeName.length(); i++) { 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 index 24efb24aa9d4..a696fe9705d5 100644 --- 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 @@ -16,13 +16,11 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.update; import static java.util.Objects.requireNonNull; -import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter.findAttributeNames; 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.resolveTopLevelAttributeName; +import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.resolveDocumentPath; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -31,17 +29,31 @@ 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; /** - * Merges update actions from POJO attributes, extensions, and request-level expressions into a single {@link UpdateExpression}. - * Merge behavior is controlled by {@link UpdateExpressionMergeStrategy}. + * 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 */ @@ -66,30 +78,74 @@ 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 + */ + public UpdateExpressionResolver build() { + 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; + } + } + /** - * Merges update actions from POJO, extension, and request sources into one {@link UpdateExpression}. Previously, all sources - * were always concatenated and sent to DynamoDB; when two actions targeted overlapping document paths (for example, replacing - * an entire attribute and also updating a nested path under that same attribute), the service responded with a "Two document - * paths overlap" error. + * Returns a single merged {@link UpdateExpression} ready for DynamoDB, or {@code null} if there is nothing to update. *

    - * To avoid a breaking change, {@link UpdateExpressionMergeStrategy} was added: it defaults to - * {@link UpdateExpressionMergeStrategy#LEGACY}, preserving that original merge behavior. When set to - * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}, the resolver drops conflicting lower-priority actions per - * top-level attribute name so the request can succeed. - * - *

      - *
    • {@link UpdateExpressionMergeStrategy#LEGACY} (default) — concatenates all actions as-is; - * overlapping paths cause a DynamoDB runtime error. As in previous behavior, null-attribute REMOVE actions - * are suppressed when the same attribute appears in an extension or request expression.
    • - * - *
    • {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE} — groups actions by top-level - * attribute name (path before first {@code .} or {@code [}). For each name, only the highest-priority - * source's actions are kept: request > extension > POJO. Different top-level names do not - * compete with each other: one attribute may contribute only request actions and another only extension actions, - * and both groups still appear in the merged expression.
    • - *
    + * 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} when no updates are needed + * @return the merged expression, or {@code null} if no actions remain * @see UpdateExpressionMergeStrategy */ public UpdateExpression resolve() { @@ -105,7 +161,7 @@ public UpdateExpression resolve() { } if (updateExpressionMergeStrategy == UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE) { - return mergeBySourcePriority(itemExpression, extensionExpression, requestExpression); + return mergeWithPathPriority(itemExpression, extensionExpression, requestExpression); } return Stream.of(itemExpression, extensionExpression, requestExpression) @@ -135,106 +191,94 @@ UpdateExpressionMergeStrategy updateExpressionMergeStrategy() { } /** - * For {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}: assigns each top-level attribute name to at most one - * source by priority (request, then extension, then POJO), then keeps only that source's actions for each assigned name. + * {@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 mergeBySourcePriority(UpdateExpression itemExpression, + private static UpdateExpression mergeWithPathPriority(UpdateExpression itemExpression, UpdateExpression extensionExpression, UpdateExpression requestExpression) { + Set requestResolvedPaths = resolvedPaths(requestExpression); + UpdateExpression extensionExpressionFiltered = + excludeOverlappingActions(extensionExpression, requestResolvedPaths); - Set requestOwned = new HashSet<>(findAttributeNames(requestExpression)); - Set extensionOwned = new HashSet<>(findAttributeNames(extensionExpression)); + Set higherPriorityResolvedPaths = new HashSet<>(requestResolvedPaths); + higherPriorityResolvedPaths.addAll(resolvedPaths(extensionExpressionFiltered)); + UpdateExpression itemExpressionFiltered = + excludeOverlappingActions(itemExpression, higherPriorityResolvedPaths); - // Request wins over extension: extension only retains attribute names not already in the request expression. - extensionOwned.removeAll(requestOwned); - - Set itemOwned = new HashSet<>(findAttributeNames(itemExpression)); - // POJO-derived item expression is the lowest priority: drop attribute names claimed by request, then by extension. - itemOwned.removeAll(requestOwned); - itemOwned.removeAll(extensionOwned); - - return Stream.of( - filterByAttributes(requestExpression, requestOwned), - filterByAttributes(extensionExpression, extensionOwned), - filterByAttributes(itemExpression, itemOwned) - ).filter(Objects::nonNull) + 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)); + } + /** - * Returns a new {@link UpdateExpression} containing only actions whose resolved top-level attribute name is in - * {@code attributeNames}, or {@code null} if nothing matches. + * 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 filterByAttributes(UpdateExpression expression, Set attributeNames) { - if (expression == null || attributeNames.isEmpty()) { + private static UpdateExpression excludeOverlappingActions(UpdateExpression expression, + Set higherPriorityResolvedPaths) { + if (expression == null) { return null; } - List retainedActions = new ArrayList<>(); - - expression.setActions().stream() - .filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames()))) - .forEach(retainedActions::add); - - expression.removeActions().stream() - .filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames()))) - .forEach(retainedActions::add); - - expression.deleteActions().stream() - .filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames()))) - .forEach(retainedActions::add); - - expression.addActions().stream() - .filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames()))) - .forEach(retainedActions::add); + if (higherPriorityResolvedPaths.isEmpty()) { + return expression; + } + List retained = streamActions(expression) + .filter(action -> !conflictsWith(resolvePath(action), higherPriorityResolvedPaths)) + .collect(Collectors.toList()); - return retainedActions.isEmpty() - ? null - : UpdateExpression.builder().actions(retainedActions).build(); + return retained.isEmpty() ? null : UpdateExpression.builder().actions(retained).build(); } - public static final class Builder { - - private TableMetadata tableMetadata; - private Map nonKeyAttributes = Collections.emptyMap(); - private UpdateExpression extensionExpression; - private UpdateExpression requestExpression; - private UpdateExpressionMergeStrategy updateExpressionMergeStrategy = UpdateExpressionMergeStrategy.LEGACY; - - public Builder tableMetadata(TableMetadata tableMetadata) { - this.tableMetadata = requireNonNull( - tableMetadata, "A TableMetadata is required when generating an Update Expression"); - return this; + private static String resolvePath(UpdateAction action) { + if (action instanceof SetAction) { + SetAction a = (SetAction) action; + return resolveDocumentPath(a.path(), a.expressionNames()); } - - public Builder nonKeyAttributes(Map nonKeyAttributes) { - if (nonKeyAttributes == null) { - this.nonKeyAttributes = Collections.emptyMap(); - } else { - this.nonKeyAttributes = Collections.unmodifiableMap(new HashMap<>(nonKeyAttributes)); - } - return this; + if (action instanceof RemoveAction) { + RemoveAction a = (RemoveAction) action; + return resolveDocumentPath(a.path(), a.expressionNames()); } - - public Builder extensionExpression(UpdateExpression extensionExpression) { - this.extensionExpression = extensionExpression; - return this; + if (action instanceof DeleteAction) { + DeleteAction a = (DeleteAction) action; + return resolveDocumentPath(a.path(), a.expressionNames()); } - - public Builder requestExpression(UpdateExpression requestExpression) { - this.requestExpression = requestExpression; - return this; - } - - public Builder updateExpressionMergeStrategy(UpdateExpressionMergeStrategy updateExpressionMergeStrategy) { - this.updateExpressionMergeStrategy = updateExpressionMergeStrategy == null - ? UpdateExpressionMergeStrategy.LEGACY - : updateExpressionMergeStrategy; - return this; + if (action instanceof AddAction) { + AddAction a = (AddAction) action; + return resolveDocumentPath(a.path(), a.expressionNames()); } + throw new IllegalArgumentException("Unsupported UpdateAction: " + action.getClass()); + } - public UpdateExpressionResolver build() { - return new UpdateExpressionResolver(this); - } + /** + * 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 f7cc97fde15d..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 @@ -19,7 +19,6 @@ import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef; import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE; -import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter.removeNestingAndListReference; import static software.amazon.awssdk.utils.CollectionUtils.filterMap; import java.util.Arrays; @@ -58,8 +57,8 @@ public static String ifNotExists(String key, String initValue) { } /** - * SET actions for every {@code itemMap} entry that is not DynamoDB NULL. For each attribute, {@link UpdateBehavior} - * is resolved from {@code tableMetadata}. + * SET actions for every {@code itemMap} entry that is not DynamoDB NULL. For each attribute, {@link UpdateBehavior} is + * resolved from {@code tableMetadata}. */ static UpdateExpression generateItemSetExpression(Map itemMap, TableMetadata tableMetadata) { @@ -105,8 +104,8 @@ static List removeActionsFor(Map attribute } /** - * 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. + * 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() @@ -117,17 +116,17 @@ static Set attributesPresentInOtherExpressions(Collection expressionNames) { + 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 removeNestingAndListReference(resolvedPath); + return resolvedPath; } /** 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 index 69a92cff23d6..1da438a75547 100644 --- 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 @@ -16,32 +16,42 @@ 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 three sources (POJO attributes, extensions, and request-level expressions) are combined before - * being sent to DynamoDB. - * + * 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: *

      - *
    • {@link #LEGACY} (default) — all actions are concatenated as-is. If two actions target overlapping - * document paths (for example, replacing an entire attribute and also updating a nested path under that same - * attribute), DynamoDB rejects the request with a "Two document paths overlap" error. The only automatic safety - * is that if a POJO attribute is {@code null}, its {@code REMOVE} action is suppressed when the same attribute - * name appears in an extension or request expression.
    • - * - *
    • {@link #PRIORITIZE_HIGHER_SOURCE} — actions are grouped by top-level attribute name (see below). - * For each name, only actions from the single highest-priority source that references that name are kept. - * Priority (highest to lowest): request > extension > POJO. Different top-level names do not compete with - * each other: one attribute may contribute only request actions and another only extension actions, and both - * groups still appear in the merged expression.
    • + *
    • {@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.
    • *
    * - *

    Top-level name (for {@link #PRIORITIZE_HIGHER_SOURCE}): resolve expression-name placeholders, then take the - * part of the path before the first {@code .} or {@code [} (for example, {@code list[0]} → {@code list}, and - * {@code object.listAttr[0]} → {@code object}). If multiple sources update paths with the same top-level name, - * only the highest-priority source's actions for that attribute are kept. - * Precedence is: request > extension > POJO. - * - *

    Default: {@link #LEGACY}. Not setting this flag preserves backward-compatible behavior. + *

    Default: {@link #LEGACY}. * * @see UpdateItemEnhancedRequest.Builder#updateExpressionMergeStrategy(UpdateExpressionMergeStrategy) * @see TransactUpdateItemEnhancedRequest.Builder#updateExpressionMergeStrategy(UpdateExpressionMergeStrategy) 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 53d61464f92e..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 @@ -349,7 +349,7 @@ public Builder item(T item) { *

    * Use {@link #updateExpressionMergeStrategy(UpdateExpressionMergeStrategy)} to control how conflicts between these * sources are resolved ({@link UpdateExpressionMergeStrategy#LEGACY concatenation} vs - * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE top-level attribute winner}). + * {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE document path overlap resolution}). * * @param updateExpression the update operations to perform * @return a builder of this type @@ -363,8 +363,8 @@ public Builder updateExpression(UpdateExpression updateExpression) { /** * 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} picks one winning source per top-level attribute name - * (see {@link UpdateExpressionMergeStrategy}). + * {@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 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 ef215b7f4435..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 @@ -50,8 +50,9 @@ *

  • 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 → request wins
  • - *
  • PRIORITIZE_HIGHER_SOURCE, list of maps, three sources → request nested update wins
  • + *
  • 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
  • @@ -431,7 +432,7 @@ public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoAndRequestSetSam // --- Merge strategy: list (POJO + extension + request) --- @Test - public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoExtensionAndRequestTouchSameList_thenRequestWins() { + public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoExtensionAndRequestTouchSameList_thenNonOverlappingIndicesCompose() { initClientWithExtensions(new ListFirstElementUpdateExtension()); RecordForUpdateExpressions record = createFullRecord(); mappedTable.putItem(record); @@ -443,7 +444,7 @@ public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoExtensionAndRequ .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); - assertThat(persistedRecord.getRequestAttributeList()).containsExactly("attr1", "request1"); + assertThat(persistedRecord.getRequestAttributeList()).containsExactly("extension0", "request1"); } // --- Merge strategy: document path (root vs nested) --- @@ -482,6 +483,9 @@ public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoSetsDocumentRoot 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) @@ -534,7 +538,7 @@ public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoExtensionAndRequ .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE)); RecordForUpdateExpressions persistedRecord = mappedTable.getItem(record); - assertThat(persistedRecord.getObjectListAttribute().get(0).getName()).isEqualTo("originObject0"); + 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"); @@ -1098,8 +1102,10 @@ public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite contex .builder() .addAction( SetAction.builder() - .path("objectListAttribute[0].name") + .path("#objectListAttribute[0].#name") .value(":extensionObject0") + .putExpressionName("#objectListAttribute", "objectListAttribute") + .putExpressionName("#name", "name") .putExpressionValue(":extensionObject0", AttributeValue.builder().s("extensionObject0").build()) .build()) 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 index 6da5fc89e7dc..e3eb8b553265 100644 --- 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 @@ -16,7 +16,6 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.update; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertNull; import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.LEGACY; import static software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE; @@ -62,7 +61,7 @@ public void resolve_legacy_emptyInputs_returnsNull() { UpdateExpression result = resolver.resolve(); - assertNull(result); + assertThat(result).isNull(); } @Test @@ -487,7 +486,7 @@ public void resolve_legacy_mergeStrategyNull_defaultsToConcatenation() { // ------------------------------------------------------- @Test - public void resolve_prioritizeHigherSource_listTouchedByPojoExtensionAndRequest_keepsRequestSetActionsOnly() { + public void resolve_prioritizeHigherSource_listTouchedByPojoExtensionAndRequest_keepsNonOverlappingExtensionAndRequestIndices() { Map itemMap = new HashMap<>(); itemMap.put("list", AttributeValue.builder().l(AttributeValue.builder().s("pojo").build()).build()); @@ -518,7 +517,12 @@ public void resolve_prioritizeHigherSource_listTouchedByPojoExtensionAndRequest_ .build() .resolve(); - assertThat(result.setActions()).containsExactly( + 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") @@ -531,7 +535,7 @@ public void resolve_prioritizeHigherSource_listTouchedByPojoExtensionAndRequest_ // ------------------------------------------------------- @Test - public void resolve_prioritizeHigherSource_siblingListIndicesUnderSameAttribute_keepsRequestSetOnly() { + public void resolve_prioritizeHigherSource_siblingListIndicesUnderSameAttribute_keepsExtensionAndRequest() { UpdateExpression extensionExpression = UpdateExpression.builder() .addAction(SetAction.builder() @@ -558,7 +562,12 @@ public void resolve_prioritizeHigherSource_siblingListIndicesUnderSameAttribute_ .build() .resolve(); - assertThat(result.setActions()).containsExactly( + 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") @@ -691,7 +700,7 @@ public void resolve_prioritizeHigherSource_objectListRootVersusNestedRequestPath // ------------------------------------------------------- @Test - public void resolve_prioritizeHigherSource_objectNestedSiblingsUnderSameTopLevelName_keepsRequestOnly() { + public void resolve_prioritizeHigherSource_objectNestedSiblingsUnderSameTopLevelName_keepsExtensionAndRequest() { UpdateExpression extensionExpression = UpdateExpression.builder() .addAction(SetAction.builder() @@ -718,7 +727,12 @@ public void resolve_prioritizeHigherSource_objectNestedSiblingsUnderSameTopLevel .build() .resolve(); - assertThat(result.setActions()).containsExactly( + 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") @@ -939,7 +953,7 @@ public void resolve_prioritizeHigherSource_threeSourcesSamePath_keepsRequestOnly // ------------------------------------------------------- @Test - public void resolve_prioritizeHigherSource_expressionNamePlaceholder_groupsByResolvedTopLevelName() { + public void resolve_prioritizeHigherSource_expressionNamePlaceholder_nonOverlappingListIndices_keepsExtensionAndRequest() { UpdateExpression extensionExpression = UpdateExpression .builder() .addAction(SetAction.builder() @@ -967,7 +981,13 @@ public void resolve_prioritizeHigherSource_expressionNamePlaceholder_groupsByRes .build() .resolve(); - assertThat(result.setActions()).containsExactly( + 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") @@ -1012,7 +1032,12 @@ public void resolve_prioritizeHigherSource_requestMultipleActionsOnSameTopLevelN .build() .resolve(); - assertThat(result.setActions()).containsExactly( + 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") @@ -1030,7 +1055,7 @@ public void resolve_prioritizeHigherSource_requestMultipleActionsOnSameTopLevelN // ------------------------------------------------------- @Test - public void resolve_prioritizeHigherSource_nestedPathsSameFirstSegment_keepsRequestOnly() { + public void resolve_prioritizeHigherSource_nestedPathsSameFirstSegment_keepsNonOverlappingSiblings() { UpdateExpression extensionExpression = UpdateExpression .builder() .addAction(SetAction.builder() @@ -1057,7 +1082,12 @@ public void resolve_prioritizeHigherSource_nestedPathsSameFirstSegment_keepsRequ .build() .resolve(); - assertThat(result.setActions()).containsExactly( + 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") @@ -1065,6 +1095,50 @@ public void resolve_prioritizeHigherSource_nestedPathsSameFirstSegment_keepsRequ .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 // ------------------------------------------------------- @@ -1076,7 +1150,7 @@ public void resolve_prioritizeHigherSource_allSourcesNull_returnsNull() { .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) .build() .resolve(); - assertNull(result); + assertThat(result).isNull(); } // ------------------------------------------------------- @@ -1109,28 +1183,6 @@ public void resolve_prioritizeHigherSource_requestOnlyNoOtherSources_returnsRequ .build()); } - @Test - public void resolve_prioritizeHigherSource_ownedAttributesButNoResolvedPathMatches_returnsNull() { - UpdateExpression extensionExpression = - UpdateExpression.builder() - .addAction(SetAction.builder() - .path("#missing") - .value(":v") - .putExpressionName("#other", "logicalAttr") - .putExpressionValue(":v", AttributeValue.builder().s("value").build()) - .build()) - .build(); - - UpdateExpression result = UpdateExpressionResolver.builder() - .tableMetadata(TABLE_METADATA) - .extensionExpression(extensionExpression) - .updateExpressionMergeStrategy(PRIORITIZE_HIGHER_SOURCE) - .build() - .resolve(); - - assertNull(result); - } - // ------------------------------------------------------- // PRIORITIZE_HIGHER_SOURCE — DELETE action from extension vs SET from request on same attribute // ------------------------------------------------------- @@ -1334,9 +1386,9 @@ public void resolve_legacy_multiSourceOverlap_concatenatesAllSetActions() { } /* - * PRIORITIZE_HIGHER_SOURCE: one SET per name; request beats extension beats item. + * 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. + * Winners: attr1 & attr2 → request; attr3 → item only (no overlap with request/extension paths). */ @Test public void resolve_prioritizeHigherSource_multiSourceOverlap_oneSetActionPerTopLevelName() { @@ -1399,4 +1451,78 @@ public void resolve_prioritizeHigherSource_multiSourceOverlap_oneSetActionPerTop .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 index 72f3cb89988f..932600f10675 100644 --- 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 @@ -326,23 +326,5 @@ public void generateItemRemoveExpression_whenOnlyNonNullAttributes_returnsNoRemo assertThat(result.removeActions()).isEmpty(); } - - @Test - public void resolveTopLevelAttributeName_whenTokenizedNestedPath_returnsTopLevelName() { - Map expressionNames = new HashMap<>(); - expressionNames.put("#customer", "customer"); - expressionNames.put("#name", "name"); - - String result = UpdateExpressionUtils.resolveTopLevelAttributeName("#customer.#name[0]", expressionNames); - - assertThat(result).isEqualTo("customer"); - } - - @Test - public void resolveTopLevelAttributeName_whenLiteralPathWithListIndex_returnsTopLevelName() { - String result = UpdateExpressionUtils.resolveTopLevelAttributeName("orders[1]", null); - - assertThat(result).isEqualTo("orders"); - } } From 335489cbca077b3269d6f6d6b2fea33075352865 Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Thu, 2 Apr 2026 19:22:28 +0300 Subject: [PATCH 8/9] Addressed PR feedback --- .../update/UpdateExpressionResolver.java | 5 +++ .../update/UpdateExpressionResolverTest.java | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) 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 index a696fe9705d5..c459b2734fd1 100644 --- 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 @@ -42,6 +42,7 @@ 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: @@ -91,8 +92,12 @@ public static final class Builder { * {@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); } 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 index e3eb8b553265..f67cc1da88ea 100644 --- 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 @@ -16,6 +16,7 @@ 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; @@ -48,6 +49,36 @@ 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) // -------------------------------------------------------------- From 520b2665c69d1bc0daf3321b363980616f5a5ebf Mon Sep 17 00:00:00 2001 From: Ana Satirbasa Date: Fri, 3 Apr 2026 09:14:33 +0300 Subject: [PATCH 9/9] Addressed PR feedback --- .../operations/UpdateItemOperationTest.java | 437 ++++++++++++++ .../UpdateItemOperationTransactTest.java | 550 ++++++++++++++++++ 2 files changed, 987 insertions(+) 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 adf8d0aa40ae..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,17 @@ 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; @@ -48,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; @@ -190,6 +206,518 @@ public void generateTransactWriteItem_withSetAction_includesSetUpdateExpression( 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); modify.accept(builder); @@ -206,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; + } }