Skip to content

Commit 22a0ffa

Browse files
committed
Support update expressions in single request update
1 parent 6144c8a commit 22a0ffa

File tree

14 files changed

+1390
-79
lines changed

14 files changed

+1390
-79
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "feature",
3+
"category": "Amazon DynamoDB Enhanced Client",
4+
"contributor": "",
5+
"description": "DynamoDb enhanced client: support UpdateExpressions in single-request update"
6+
}

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/UpdateItemOperation.java

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,10 @@
1717

1818
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue;
1919
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.readAndTransformSingleItem;
20-
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.operationExpression;
2120
import static software.amazon.awssdk.utils.CollectionUtils.filterMap;
2221

2322
import java.util.Collection;
2423
import java.util.HashMap;
25-
import java.util.List;
2624
import java.util.Map;
2725
import java.util.Optional;
2826
import java.util.concurrent.CompletableFuture;
@@ -36,6 +34,7 @@
3634
import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification;
3735
import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext;
3836
import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter;
37+
import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionResolver;
3938
import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode;
4039
import software.amazon.awssdk.enhanced.dynamodb.model.TransactUpdateItemEnhancedRequest;
4140
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest;
@@ -132,7 +131,7 @@ public UpdateItemRequest generateRequest(TableSchema<T> tableSchema,
132131
Map<String, AttributeValue> keyAttributes = filterMap(itemMap, entry -> primaryKeys.contains(entry.getKey()));
133132
Map<String, AttributeValue> nonKeyAttributes = filterMap(itemMap, entry -> !primaryKeys.contains(entry.getKey()));
134133

135-
Expression updateExpression = generateUpdateExpressionIfExist(tableMetadata, transformation, nonKeyAttributes);
134+
Expression updateExpression = generateUpdateExpressionIfExist(tableMetadata, transformation, nonKeyAttributes, request);
136135
Expression conditionExpression = generateConditionExpressionIfExist(transformation, request);
137136

138137
Map<String, String> expressionNames = coalesceExpressionNames(updateExpression, conditionExpression);
@@ -271,27 +270,39 @@ public TransactWriteItem generateTransactWriteItem(TableSchema<T> tableSchema, O
271270
}
272271

273272
/**
274-
* Retrieves the UpdateExpression from extensions if existing, and then creates an UpdateExpression for the request POJO
275-
* if there are attributes to be updated (most likely). If both exist, they are merged and the code generates a final
276-
* Expression that represent the result.
273+
* Merges UpdateExpressions from three sources in priority order: POJO attributes (lowest),
274+
* extensions (medium), request (highest). Higher priority sources override conflicting actions.
275+
*
276+
* <p>Null POJO attributes normally generate REMOVE actions, but are skipped if the same
277+
* attribute is referenced in extension/request expressions to avoid DynamoDB conflicts.
278+
*
279+
* @param tableMetadata metadata about the table structure
280+
* @param transformation write modification from extensions containing UpdateExpression
281+
* @param attributes non-key attributes from the POJO item
282+
* @param request the update request containing optional explicit UpdateExpression
283+
* @return merged Expression containing the final update expression, or null if no updates needed
277284
*/
278-
private Expression generateUpdateExpressionIfExist(TableMetadata tableMetadata,
279-
WriteModification transformation,
280-
Map<String, AttributeValue> attributes) {
281-
UpdateExpression updateExpression = null;
282-
if (transformation != null && transformation.updateExpression() != null) {
283-
updateExpression = transformation.updateExpression();
284-
}
285-
if (!attributes.isEmpty()) {
286-
List<String> nonRemoveAttributes = UpdateExpressionConverter.findAttributeNames(updateExpression);
287-
UpdateExpression operationUpdateExpression = operationExpression(attributes, tableMetadata, nonRemoveAttributes);
288-
if (updateExpression == null) {
289-
updateExpression = operationUpdateExpression;
290-
} else {
291-
updateExpression = UpdateExpression.mergeExpressions(updateExpression, operationUpdateExpression);
292-
}
293-
}
294-
return UpdateExpressionConverter.toExpression(updateExpression);
285+
private Expression generateUpdateExpressionIfExist(
286+
TableMetadata tableMetadata,
287+
WriteModification transformation,
288+
Map<String, AttributeValue> attributes,
289+
Either<UpdateItemEnhancedRequest<T>, TransactUpdateItemEnhancedRequest<T>> request) {
290+
291+
UpdateExpression requestUpdateExpression =
292+
request.map(r -> Optional.ofNullable(r.updateExpression()),
293+
r -> Optional.ofNullable(r.updateExpression()))
294+
.orElse(null);
295+
296+
UpdateExpressionResolver updateExpressionResolver =
297+
UpdateExpressionResolver.builder()
298+
.tableMetadata(tableMetadata)
299+
.itemNonKeyAttributes(attributes)
300+
.requestExpression(requestUpdateExpression)
301+
.extensionExpression(transformation != null ? transformation.updateExpression() : null)
302+
.build();
303+
304+
UpdateExpression mergedUpdateExpression = updateExpressionResolver.resolve();
305+
return UpdateExpressionConverter.toExpression(mergedUpdateExpression);
295306
}
296307

297308
/**

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionConverter.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,13 @@ private UpdateExpressionConverter() {
7373
* of whether it represents an update expression, conditional expression or another type of expression, since once
7474
* the string is generated that update expression is the final format accepted by DDB.
7575
*
76-
* @return an Expression representing the concatenation of all actions in this UpdateExpression
76+
* @param expression the UpdateExpression to convert
77+
*
78+
* @return an Expression representing the concatenation of all actions in this UpdateExpression, or null if the expression
79+
* is null or empty (contains no actions) to avoid generating invalid empty expressions that would be rejected by DynamoDB.
7780
*/
7881
public static Expression toExpression(UpdateExpression expression) {
79-
if (expression == null) {
82+
if (expression == null || expression.isEmpty()) {
8083
return null;
8184
}
8285
Map<String, AttributeValue> expressionValues = mergeExpressionValues(expression);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.enhanced.dynamodb.internal.update;
17+
18+
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue;
19+
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.removeActionsFor;
20+
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.setActionsFor;
21+
import static software.amazon.awssdk.utils.CollectionUtils.filterMap;
22+
23+
import java.util.Arrays;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.stream.Collectors;
27+
import software.amazon.awssdk.annotations.SdkInternalApi;
28+
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
29+
import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression;
30+
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
31+
32+
/**
33+
* Resolves and merges UpdateExpressions from multiple sources (item attributes, extensions, requests)
34+
* with priority-based conflict resolution and smart filtering to prevent attribute conflicts.
35+
*/
36+
@SdkInternalApi
37+
public final class UpdateExpressionResolver {
38+
39+
private final UpdateExpression extensionExpression;
40+
private final UpdateExpression requestExpression;
41+
private final Map<String, AttributeValue> itemNonKeyAttributes;
42+
private final TableMetadata tableMetadata;
43+
44+
private UpdateExpressionResolver(Builder builder) {
45+
this.extensionExpression = builder.extensionExpression;
46+
this.requestExpression = builder.requestExpression;
47+
this.itemNonKeyAttributes = builder.nonKeyAttributes;
48+
this.tableMetadata = builder.tableMetadata;
49+
}
50+
51+
public static Builder builder() {
52+
return new Builder();
53+
}
54+
55+
/**
56+
* Merges UpdateExpressions from three sources with priority: item attributes (lowest),
57+
* extension expressions (medium), request expressions (highest).
58+
*
59+
* <p><b>Steps:</b> Identify attributes used by extensions/requests to prevent REMOVE conflicts →
60+
* create item SET/REMOVE actions → merge extensions (override item) → merge request (override all).
61+
*
62+
* <p><b>Backward compatibility:</b> Without request expressions, behavior is identical to previous versions.
63+
* <p><b>Exceptions:</b> DynamoDbException may be thrown when the same attribute is updated by multiple sources.
64+
*
65+
* @return merged UpdateExpression, or empty if no updates needed
66+
*/
67+
public UpdateExpression resolve() {
68+
List<String> excludedFromRemoval = attributesPresentInExpressions(Arrays.asList(extensionExpression, requestExpression));
69+
70+
UpdateExpression itemSetExpression = generateItemSetExpression(itemNonKeyAttributes, tableMetadata);
71+
UpdateExpression itemRemoveExpression = generateItemRemoveExpression(itemNonKeyAttributes, excludedFromRemoval);
72+
UpdateExpression itemFinalExpression = UpdateExpression.mergeExpressions(itemSetExpression, itemRemoveExpression);
73+
74+
UpdateExpression itemAndExtensionExpression = UpdateExpression.mergeExpressions(extensionExpression, itemFinalExpression);
75+
return UpdateExpression.mergeExpressions(requestExpression, itemAndExtensionExpression);
76+
}
77+
78+
private static List<String> attributesPresentInExpressions(List<UpdateExpression> updateExpressions) {
79+
return updateExpressions.stream()
80+
.map(UpdateExpressionConverter::findAttributeNames)
81+
.flatMap(List::stream)
82+
.collect(Collectors.toList());
83+
}
84+
85+
public static UpdateExpression generateItemSetExpression(Map<String, AttributeValue> itemMap,
86+
TableMetadata tableMetadata) {
87+
88+
Map<String, AttributeValue> setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue()));
89+
return UpdateExpression.builder()
90+
.actions(setActionsFor(setAttributes, tableMetadata))
91+
.build();
92+
}
93+
94+
public static UpdateExpression generateItemRemoveExpression(Map<String, AttributeValue> itemMap,
95+
List<String> nonRemoveAttributes) {
96+
Map<String, AttributeValue> removeAttributes =
97+
filterMap(itemMap, e -> isNullAttributeValue(e.getValue()) && !nonRemoveAttributes.contains(e.getKey()));
98+
99+
return UpdateExpression.builder()
100+
.actions(removeActionsFor(removeAttributes))
101+
.build();
102+
}
103+
104+
public static final class Builder {
105+
106+
private TableMetadata tableMetadata;
107+
private UpdateExpression extensionExpression;
108+
private UpdateExpression requestExpression;
109+
private Map<String, AttributeValue> nonKeyAttributes;
110+
111+
public Builder tableMetadata(TableMetadata tableMetadata) {
112+
this.tableMetadata = tableMetadata;
113+
return this;
114+
}
115+
116+
public Builder extensionExpression(UpdateExpression extensionExpression) {
117+
this.extensionExpression = extensionExpression;
118+
return this;
119+
}
120+
121+
public Builder itemNonKeyAttributes(Map<String, AttributeValue> nonKeyAttributes) {
122+
this.nonKeyAttributes = nonKeyAttributes;
123+
return this;
124+
}
125+
126+
public Builder requestExpression(UpdateExpression requestExpression) {
127+
this.requestExpression = requestExpression;
128+
return this;
129+
}
130+
131+
public UpdateExpressionResolver build() {
132+
return new UpdateExpressionResolver(this);
133+
}
134+
135+
}
136+
}

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/update/UpdateExpressionUtils.java

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,9 @@
1515

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

18-
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue;
1918
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef;
2019
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef;
2120
import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE;
22-
import static software.amazon.awssdk.utils.CollectionUtils.filterMap;
2321

2422
import java.util.Arrays;
2523
import java.util.Collections;
@@ -35,7 +33,6 @@
3533
import software.amazon.awssdk.enhanced.dynamodb.mapper.UpdateBehavior;
3634
import software.amazon.awssdk.enhanced.dynamodb.update.RemoveAction;
3735
import software.amazon.awssdk.enhanced.dynamodb.update.SetAction;
38-
import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression;
3936
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
4037

4138
@SdkInternalApi
@@ -53,32 +50,10 @@ public static String ifNotExists(String key, String initValue) {
5350
return "if_not_exists(" + keyRef(key) + ", " + valueRef(initValue) + ")";
5451
}
5552

56-
/**
57-
* Generates an UpdateExpression representing a POJO, with only SET and REMOVE actions.
58-
*/
59-
public static UpdateExpression operationExpression(Map<String, AttributeValue> itemMap,
60-
TableMetadata tableMetadata,
61-
List<String> nonRemoveAttributes) {
62-
63-
Map<String, AttributeValue> setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue()));
64-
UpdateExpression setAttributeExpression = UpdateExpression.builder()
65-
.actions(setActionsFor(setAttributes, tableMetadata))
66-
.build();
67-
68-
Map<String, AttributeValue> removeAttributes =
69-
filterMap(itemMap, e -> isNullAttributeValue(e.getValue()) && !nonRemoveAttributes.contains(e.getKey()));
70-
71-
UpdateExpression removeAttributeExpression = UpdateExpression.builder()
72-
.actions(removeActionsFor(removeAttributes))
73-
.build();
74-
75-
return UpdateExpression.mergeExpressions(setAttributeExpression, removeAttributeExpression);
76-
}
77-
7853
/**
7954
* Creates a list of SET actions for all attributes supplied in the map.
8055
*/
81-
private static List<SetAction> setActionsFor(Map<String, AttributeValue> attributesToSet, TableMetadata tableMetadata) {
56+
public static List<SetAction> setActionsFor(Map<String, AttributeValue> attributesToSet, TableMetadata tableMetadata) {
8257
return attributesToSet.entrySet()
8358
.stream()
8459
.map(entry -> setValue(entry.getKey(),
@@ -90,7 +65,7 @@ private static List<SetAction> setActionsFor(Map<String, AttributeValue> attribu
9065
/**
9166
* Creates a list of REMOVE actions for all attributes supplied in the map.
9267
*/
93-
private static List<RemoveAction> removeActionsFor(Map<String, AttributeValue> attributesToSet) {
68+
public static List<RemoveAction> removeActionsFor(Map<String, AttributeValue> attributesToSet) {
9469
return attributesToSet.entrySet()
9570
.stream()
9671
.map(entry -> remove(entry.getKey()))

0 commit comments

Comments
 (0)