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,8 +34,10 @@
import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification;
import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext;
import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter;
import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionResolver;
import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode;
import software.amazon.awssdk.enhanced.dynamodb.model.TransactUpdateItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression;
Expand Down Expand Up @@ -132,7 +132,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 +271,37 @@ 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.
* Combines POJO, extension, and request update expressions via {@link UpdateExpressionResolver}, honoring the request's
* {@link UpdateExpressionMergeStrategy}. For {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}, see
* {@link UpdateExpressionMergeStrategy} (one winning source per top-level attribute name).
*/
private Expression generateUpdateExpressionIfExist(TableMetadata tableMetadata,
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));

UpdateExpressionMergeStrategy updateExpressionMergeStrategy =
request.left().map(UpdateItemEnhancedRequest::updateExpressionMergeStrategy)
.orElseGet(() -> request.right()
.map(TransactUpdateItemEnhancedRequest::updateExpressionMergeStrategy)
.orElse(UpdateExpressionMergeStrategy.LEGACY));

UpdateExpressionResolver updateExpressionResolver =
UpdateExpressionResolver.builder()
.tableMetadata(tableMetadata)
.nonKeyAttributes(attributes)
.requestExpression(requestUpdateExpression)
.updateExpressionMergeStrategy(updateExpressionMergeStrategy)
.extensionExpression(transformation != null ? transformation.updateExpression() : null)
.build();

UpdateExpression mergedUpdateExpression = updateExpressionResolver.resolve();
return UpdateExpressionConverter.toExpression(mergedUpdateExpression);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@ public static List<String> findAttributeNames(UpdateExpression updateExpression)
return attributeNames;
}

/**
* Returns the <em>top-level</em> segment of a DynamoDB update expression document path: the substring before the first
* {@code .} (nested map attribute) or {@code [} (list index). For example, {@code attr}, {@code attr[0]}, and
* {@code attr.nested} all share the same top-level name {@code attr}, which is the DynamoDB attribute used for grouping and
* overlap rules.
*
* @param attributeName a path or name segment after any {@code #} expression-name substitution; must not be {@code null}
*/
static String removeNestingAndListReference(String attributeName) {
return attributeName.substring(0, getRemovalIndex(attributeName));
}

private static List<String> groupExpressions(UpdateExpression expression) {
List<String> groupExpressions = new ArrayList<>();
if (!expression.setActions().isEmpty()) {
Expand Down Expand Up @@ -216,9 +228,6 @@ private static List<String> listAttributeNamesFromTokens(UpdateExpression update
.collect(Collectors.toList());
}

private static String removeNestingAndListReference(String attributeName) {
return attributeName.substring(0, getRemovalIndex(attributeName));
}

private static int getRemovalIndex(String attributeName) {
for (int i = 0; i < attributeName.length(); i++) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

package software.amazon.awssdk.enhanced.dynamodb.internal.update;

import static java.util.Objects.requireNonNull;
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter.findAttributeNames;
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.attributesPresentInOtherExpressions;
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.generateItemRemoveExpression;
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.generateItemSetExpression;
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.resolveTopLevelAttributeName;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateExpressionMergeStrategy;
import software.amazon.awssdk.enhanced.dynamodb.update.UpdateAction;
import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;

/**
* Merges update actions from POJO attributes, extensions, and request-level expressions into a single {@link UpdateExpression}.
* Merge behavior is controlled by {@link UpdateExpressionMergeStrategy}.
*
* @see UpdateExpressionMergeStrategy
*/
@SdkInternalApi
public final class UpdateExpressionResolver {

private final TableMetadata tableMetadata;
private final Map<String, AttributeValue> nonKeyAttributes;
private final UpdateExpression extensionExpression;
private final UpdateExpression requestExpression;
private final UpdateExpressionMergeStrategy updateExpressionMergeStrategy;

private UpdateExpressionResolver(Builder builder) {
this.tableMetadata = builder.tableMetadata;
this.nonKeyAttributes = builder.nonKeyAttributes;
this.extensionExpression = builder.extensionExpression;
this.requestExpression = builder.requestExpression;
this.updateExpressionMergeStrategy = builder.updateExpressionMergeStrategy;
}

public static Builder builder() {
return new Builder();
}

/**
* Merges update actions from POJO, extension, and request sources into one {@link UpdateExpression}. Previously, all sources
* were always concatenated and sent to DynamoDB; when two actions targeted overlapping document paths (for example, replacing
* an entire attribute and also updating a nested path under that same attribute), the service responded with a "Two document
* paths overlap" error.
* <p>
* To avoid a breaking change, {@link UpdateExpressionMergeStrategy} was added: it defaults to
* {@link UpdateExpressionMergeStrategy#LEGACY}, preserving that original merge behavior. When set to
* {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}, the resolver drops conflicting lower-priority actions per
* top-level attribute name so the request can succeed.
*
* <ul>
* <li><b>{@link UpdateExpressionMergeStrategy#LEGACY}</b> (default) &mdash; concatenates all actions as-is;
* overlapping paths cause a DynamoDB runtime error. As in previous behavior, null-attribute REMOVE actions
* are suppressed when the same attribute appears in an extension or request expression.</li>
*
* <li><b>{@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}</b> &mdash; groups actions by top-level
* attribute name (path before first {@code .} or {@code [}). For each name, only the highest-priority
* source's actions are kept: <em>request &gt; extension &gt; POJO</em>. Different top-level names do not
* compete with each other: one attribute may contribute only request actions and another only extension actions,
* and both groups still appear in the merged expression.</li>
* </ul>
*
* @return the merged expression, or {@code null} when no updates are needed
* @see UpdateExpressionMergeStrategy
*/
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));
}

if (updateExpressionMergeStrategy == UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE) {
return mergeBySourcePriority(itemExpression, extensionExpression, requestExpression);
}

return Stream.of(itemExpression, extensionExpression, requestExpression)
.filter(Objects::nonNull)
.reduce(UpdateExpression::mergeExpressions)
.orElse(null);
}

TableMetadata tableMetadata() {
return tableMetadata;
}

Map<String, AttributeValue> nonKeyAttributes() {
return nonKeyAttributes;
}

UpdateExpression extensionExpression() {
return extensionExpression;
}

UpdateExpression requestExpression() {
return requestExpression;
}

UpdateExpressionMergeStrategy updateExpressionMergeStrategy() {
return updateExpressionMergeStrategy;
}

/**
* For {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}: assigns each top-level attribute name to at most one
* source by priority (request, then extension, then POJO), then keeps only that source's actions for each assigned name.
*/
private static UpdateExpression mergeBySourcePriority(UpdateExpression itemExpression,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Item in DynamoDB: { "profile": { "name": "Alice", "city": "London" } }

Extension sets:  profile.name  → "Bob"     (e.g. an audit/normalisation extension)
Request sets:    profile.city  → "Paris"   (user wants to update only the city)

Both paths share top-level name profile. mergeBySourcePriority assigns profile to the request (highest priority), then does extensionOwned.removeAll(requestOwned) so profile is removed from extensionOwned. The extension's profile.name action is then filtered out by filterByAttributes. Only profile.city = "Paris" is sent to DynamoDB.

Result: profile.name silently stays "Alice" instead of being updated to "Bob". DynamoDB would have accepted both actions without any conflict profile.name and profile.city are completely independent paths.

Details:
PRIORITIZE_HIGHER_SOURCE groups actions by top-level attribute name and keeps only the highest-priority source's actions for each name. This is too coarse: it drops lower-priority actions even when their specific paths don't actually overlap with the higher-priority source's paths.

Example: extension sets profile.name, request sets profile.city. Both resolve to top-level name profile, so the extension's action is dropped but DynamoDB would accept both in the same request without any conflict, since profile.name and profile.city are independent paths.

True DynamoDB path overlap only occurs when one path is a prefix of another (e.g. profile vs profile.name, or list vs list[0]). Sibling paths like profile.name and profile.city do not overlap.

Suggested fix: replace the top-level-name grouping with a prefix check. For each lower-priority action, only drop it if a higher-priority action's path is a prefix of it (or vice versa):

private static boolean conflictsWith(String candidatePath, Set<String> higherPriorityPaths) {
    for (String higher : higherPriorityPaths) {
        // overlap: one path is a prefix of the other
        if (candidatePath.equals(higher)
            || candidatePath.startsWith(higher + ".")
            || candidatePath.startsWith(higher + "[")
            || higher.startsWith(candidatePath + ".")
            || higher.startsWith(candidatePath + "[")) {
            return true;
        }
    }
    return false;
}

Then in mergeBySourcePriority, instead of removing whole top-level names from extensionOwned/itemOwned, filter individual actions by whether their resolved full path conflicts with any higher-priority action's full path. This preserves non-conflicting sibling actions from lower-priority sources while still correctly resolving true overlaps.

Note: the current Javadoc on UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE and the resolve() method should also be updated to accurately describe whichever behavior is chosen, so users can reason about what will and won't be sent to DynamoDB.

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 suggested changes were implemented:

  • Real overlap: same path, or parent vs child (e.g. parent vs parent.child, items vs items[0]) → keep the higher-priority source (request → extension → item).
  • No overlap: different leaves under the same map (e.g. profile.name vs profile.city) → keep both.
  • Lists: different indices, including POJO + extension + request and paths with # placeholders → keep each non-overlapping index.

New tests were added in:

  • UpdateExpressionTest.java
  • UpdateItemOperationTest.java
  • UpdateExpressionResolverTest.java

Javadocs on UpdateExpressionMergeStrategy, UpdateExpressionResolver.resolve() and related request / operation types were updated to match the changes.

UpdateExpression extensionExpression,
UpdateExpression requestExpression) {

Set<String> requestOwned = new HashSet<>(findAttributeNames(requestExpression));
Set<String> extensionOwned = new HashSet<>(findAttributeNames(extensionExpression));

// Request wins over extension: extension only retains attribute names not already in the request expression.
extensionOwned.removeAll(requestOwned);

Set<String> itemOwned = new HashSet<>(findAttributeNames(itemExpression));
// POJO-derived item expression is the lowest priority: drop attribute names claimed by request, then by extension.
itemOwned.removeAll(requestOwned);
itemOwned.removeAll(extensionOwned);

return Stream.of(
filterByAttributes(requestExpression, requestOwned),
filterByAttributes(extensionExpression, extensionOwned),
filterByAttributes(itemExpression, itemOwned)
).filter(Objects::nonNull)
.reduce(UpdateExpression::mergeExpressions)
.orElse(null);
}

/**
* Returns a new {@link UpdateExpression} containing only actions whose resolved top-level attribute name is in
* {@code attributeNames}, or {@code null} if nothing matches.
*/
private static UpdateExpression filterByAttributes(UpdateExpression expression, Set<String> attributeNames) {
if (expression == null || attributeNames.isEmpty()) {
return null;
}
List<UpdateAction> retainedActions = new ArrayList<>();

expression.setActions().stream()
.filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames())))
.forEach(retainedActions::add);

expression.removeActions().stream()
.filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames())))
.forEach(retainedActions::add);

expression.deleteActions().stream()
.filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames())))
.forEach(retainedActions::add);

expression.addActions().stream()
.filter(act -> attributeNames.contains(resolveTopLevelAttributeName(act.path(), act.expressionNames())))
.forEach(retainedActions::add);

return retainedActions.isEmpty()
? null
: UpdateExpression.builder().actions(retainedActions).build();
}

public static final class Builder {

private TableMetadata tableMetadata;
private Map<String, AttributeValue> nonKeyAttributes = Collections.emptyMap();
private UpdateExpression extensionExpression;
private UpdateExpression requestExpression;
private UpdateExpressionMergeStrategy updateExpressionMergeStrategy = UpdateExpressionMergeStrategy.LEGACY;

public Builder tableMetadata(TableMetadata tableMetadata) {
this.tableMetadata = requireNonNull(
tableMetadata, "A TableMetadata is required when generating an Update Expression");
return this;
}

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 Builder updateExpressionMergeStrategy(UpdateExpressionMergeStrategy updateExpressionMergeStrategy) {
this.updateExpressionMergeStrategy = updateExpressionMergeStrategy == null
? UpdateExpressionMergeStrategy.LEGACY
: updateExpressionMergeStrategy;
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

}
}
Loading