Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "feature",
"category": "Amazon DynamoDB Enhanced Client",
"contributor": "",
"description": "Support update expressions in single request update"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -132,7 +131,7 @@ public UpdateItemRequest generateRequest(TableSchema<T> tableSchema,
Map<String, AttributeValue> keyAttributes = filterMap(itemMap, entry -> primaryKeys.contains(entry.getKey()));
Map<String, AttributeValue> 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<String, String> expressionNames = coalesceExpressionNames(updateExpression, conditionExpression);
Expand Down Expand Up @@ -271,27 +270,38 @@ public TransactWriteItem generateTransactWriteItem(TableSchema<T> 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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to clarify the javadoc here, If I understand this right,

Given a POJO with: item.setCounter(100L)
And a request UpdateExpression: SET counter = counter + :inc

The resolver produces BOTH actions (concat, not override):
SET #AMZN_MAPPED_counter = :AMZN_MAPPED_counter, counter = counter + :inc

DynamoDB rejects this with: "Two document paths overlap with each other"

The only conflict that IS auto-resolved: if item.setCounter(null), the POJO would generate REMOVE counter, but the resolver suppresses that REMOVE because counter appears in the request expression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation and javadoc were updated and details related to the new approach are captured here.

*
* <p>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<String, AttributeValue> attributes) {
UpdateExpression updateExpression = null;
if (transformation != null && transformation.updateExpression() != null) {
updateExpression = transformation.updateExpression();
}
if (!attributes.isEmpty()) {
List<String> 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(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateItemOperationTest can be updated as part of this PR to add tests for the new updateExpression and updateExpressionMergeStrategy fields flowing through generateRequest()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateItemOperationTest and UpdateItemOperationTransactTest were updated with the following scenarios:

Scenario Result
Request only Only the request update expression.
Extension + request, different paths (LEGACY or PRIORITIZE with no overlap) The full extension expression and the full request expression—every action from each, on its own paths.
Extension + request, same path (PRIORITIZE_HIGHER_SOURCE) The request side for that path; extension actions that hit the same path are dropped.
Extension + request, same path (LEGACY) Both extension and request actions on that path, all chained together.
POJO + request (LEGACY) Everything from the item map plus everything from the request expression.
POJO + extension (LEGACY) Everything from the item map plus everything from the extension expression.
POJO + extension + request (LEGACY) Item, extension, and request contributions, all merged.
POJO + request (PRIORITIZE_HIGHER_SOURCE, same attribute in both) Request wins on the shared attribute; the item still supplies updates for attributes the request does not take over.
POJO + extension (PRIORITIZE_HIGHER_SOURCE, same attribute in both) Extension wins on the shared attribute; the item still supplies updates for attributes the extension does not take over.
POJO + extension + request (PRIORITIZE_HIGHER_SOURCE) Request actions first; then extension actions that do not collide with the request; then item actions that do not collide with either.
Transact (same setups) Same rules; the merged result is what appears on both the plain UpdateItemRequest and the transact Update payload.

TableMetadata tableMetadata,
WriteModification transformation,
Map<String, AttributeValue> attributes,
Either<UpdateItemEnhancedRequest<T>, TransactUpdateItemEnhancedRequest<T>> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, AttributeValue> 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).
*
* <p><b>Steps:</b> Identify attributes used by extensions/requests to prevent REMOVE conflicts →
* create item SET/REMOVE actions → merge extensions (override item) → merge request (override all).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not happening, this concatenates and DynamoDB will throw a conflict error, see updateExpressionInRequest_whenAttributeAlsoInPojo_shouldThrowConflictError test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The referenced test is no longer present because the implementation was changed to add an opt-in flag (updateExpressionMergeStrategy) in order to preserve backward compatibility.

Default behavior remains LEGACY: actions are concatenated and overlapping paths may still fail in DynamoDB (Two document paths overlap).

Conflict resolution is applied only when users opt into PRIORITIZE_HIGHER_SOURCE (request > extension > POJO).

The javadoc was updated and additional details are captured here.

*
* <p><b>Backward compatibility:</b> Without request expressions, behavior is identical to previous versions.
* <p><b>Exceptions:</b> 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<String> 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<String> attributesPresentInOtherExpressions(Collection<UpdateExpression> updateExpressions) {
return updateExpressions.stream()
.filter(Objects::nonNull)
.map(UpdateExpressionConverter::findAttributeNames)
.flatMap(List::stream)
.collect(Collectors.toSet());
}

public static UpdateExpression generateItemSetExpression(Map<String, AttributeValue> itemMap,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be public ?

Copy link
Copy Markdown
Contributor Author

@anasatirbasa anasatirbasa Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method was moved to UpdateExpressionUtils to centralize shared update-expression construction logic and keep the resolver focused on merge behavior.

This was also made package-private, since it's only used by UpdateExpressionResolver and tests in the same package.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be package private as it is used only by unit tests?
Same for generateItemRemoveExpression.

Copy link
Copy Markdown
Contributor Author

@anasatirbasa anasatirbasa Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two methods were moved to UpdateExpressionUtils to centralize shared update-expression construction logic and keep the resolver focused on merge behavior.

Thay were also made package-private, since they are just used by UpdateExpressionResolver and tests in the same package.

TableMetadata tableMetadata) {

Map<String, AttributeValue> setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue()));
return UpdateExpression.builder()
.actions(setActionsFor(setAttributes, tableMetadata))
.build();
}

public static UpdateExpression generateItemRemoveExpression(Map<String, AttributeValue> itemMap,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be public ?

Copy link
Copy Markdown
Contributor Author

@anasatirbasa anasatirbasa Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method was moved to UpdateExpressionUtils to centralize shared update-expression construction logic and keep the resolver focused on merge behavior.

This was also made package-private, since it's only used by UpdateExpressionResolver and tests in the same package.

Collection<String> nonRemoveAttributes) {
Map<String, AttributeValue> 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<String, AttributeValue> 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<String, AttributeValue> 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() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing Javadoc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Javadoc was added.

return new UpdateExpressionResolver(this);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If nonKeyAttributes() is never called on the builder, this.nonKeyAttributes remains null. The resolve() method calls nonKeyAttributes.isEmpty() which will throw NullPointerException.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null handling was added in Builder.nonKeyAttributes(...) to set Collections.emptyMap() when null is passed, so resolve() wil not throw NullPointerException:

public Builder nonKeyAttributes(Map<String, AttributeValue> nonKeyAttributes) {
   if (nonKeyAttributes == null) {
        this.nonKeyAttributes = Collections.emptyMap();
   } else {
        this.nonKeyAttributes = Collections.unmodifiableMap(new HashMap<>(nonKeyAttributes));
    }
    return this;
    }

}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tableMetadata has no default value, so if the caller never calls .tableMetadata(...), the field is null. resolve() passes tableMetadata to generateItemSetExpression, which passes it to UpdateBehaviorTag.resolveForAttribute, which will NPE. The requireNonNull guard is only on the setter, it doesn't protect against the setter never being called.

So, it may cause a NPE at runtime with a confusing stack trace instead of a clear IllegalStateException at build time.

You can add validation in build():

public UpdateExpressionResolver build() {
    Validate.paramNotNull(tableMetadata, "tableMetadata");
    return new UpdateExpressionResolver(this);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added validation in UpdateExpressionResolver:

public UpdateExpressionResolver build() {
            if (!nonKeyAttributes.isEmpty()) {
                Validate.paramNotNull(tableMetadata, "tableMetadata");
            }
            return new UpdateExpressionResolver(this);
        }

and tests in UpdateExpressionResolverTest that cover this scenario:

  • build_nonKeyAttributesWithoutTableMetadata_throwsNullPointerException
  • build_emptyNonKeyAttributesWithoutTableMetadata_succeeds


}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have new line at the end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added new line.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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<String, AttributeValue> itemMap,
TableMetadata tableMetadata,
List<String> nonRemoveAttributes) {

Map<String, AttributeValue> setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue()));
UpdateExpression setAttributeExpression = UpdateExpression.builder()
.actions(setActionsFor(setAttributes, tableMetadata))
.build();

Map<String, AttributeValue> 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<SetAction> setActionsFor(Map<String, AttributeValue> attributesToSet, TableMetadata tableMetadata) {
static List<SetAction> setActionsFor(Map<String, AttributeValue> attributesToSet, TableMetadata tableMetadata) {
return attributesToSet.entrySet()
.stream()
.map(entry -> setValue(entry.getKey(),
Expand All @@ -90,7 +65,7 @@ private static List<SetAction> setActionsFor(Map<String, AttributeValue> attribu
/**
* Creates a list of REMOVE actions for all attributes supplied in the map.
*/
private static List<RemoveAction> removeActionsFor(Map<String, AttributeValue> attributesToSet) {
static List<RemoveAction> removeActionsFor(Map<String, AttributeValue> attributesToSet) {
return attributesToSet.entrySet()
.stream()
.map(entry -> remove(entry.getKey()))
Expand Down
Loading