diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-d40ed28.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-d40ed28.json new file mode 100644 index 000000000000..c214e8b6afe7 --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-d40ed28.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "DynamoDb enhanced client: support UpdateExpressions 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..febf68cdb89b 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,22 @@ 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 attribute_notInPojo_notFilteredInExtension_ignoresNulls_updatesNormally() { initClientWithExtensions(new ItemPreservingUpdateExtension()); - RecordForUpdateExpressions record = createRecordWithoutExtensionAttributes(); + RecordForUpdateExpressions record = createFullRecord(); mappedTable.updateItem(r -> r.item(record).ignoreNulls(true)); @@ -83,7 +98,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 +110,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 +122,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 +138,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 +147,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 +161,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 +174,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 +187,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 +204,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 +233,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 +241,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 +637,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..241163d3e274 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,6 +27,7 @@ public class RecordForUpdateExpressions { private String id; private String stringAttribute1; + private List requestAttributeList; private Long extensionAttribute1; private Set extensionAttribute2; @@ -47,6 +49,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; } 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()); + } +} +