Skip to content

Commit a7fe576

Browse files
committed
Support update expressions in single request update
1 parent 81f33e0 commit a7fe576

File tree

10 files changed

+1351
-73
lines changed

10 files changed

+1351
-73
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: 33 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,38 @@ 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.left().map(UpdateItemEnhancedRequest::updateExpression)
293+
.orElseGet(() -> request.right().map(TransactUpdateItemEnhancedRequest::updateExpression).orElse(null));
294+
295+
UpdateExpressionResolver updateExpressionResolver =
296+
UpdateExpressionResolver.builder()
297+
.tableMetadata(tableMetadata)
298+
.nonKeyAttributes(attributes)
299+
.requestExpression(requestUpdateExpression)
300+
.extensionExpression(transformation != null ? transformation.updateExpression() : null)
301+
.build();
302+
303+
UpdateExpression mergedUpdateExpression = updateExpressionResolver.resolve();
304+
return UpdateExpressionConverter.toExpression(mergedUpdateExpression);
295305
}
296306

297307
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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 java.util.Objects.requireNonNull;
19+
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue;
20+
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.removeActionsFor;
21+
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.setActionsFor;
22+
import static software.amazon.awssdk.utils.CollectionUtils.filterMap;
23+
24+
import java.util.Arrays;
25+
import java.util.Collection;
26+
import java.util.Collections;
27+
import java.util.HashMap;
28+
import java.util.List;
29+
import java.util.Map;
30+
import java.util.Objects;
31+
import java.util.Set;
32+
import java.util.stream.Collectors;
33+
import java.util.stream.Stream;
34+
import software.amazon.awssdk.annotations.SdkInternalApi;
35+
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
36+
import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression;
37+
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
38+
39+
/**
40+
* Resolves and merges UpdateExpressions from multiple sources (item attributes, extensions, requests) with priority-based
41+
* conflict resolution and smart filtering to prevent attribute conflicts.
42+
*/
43+
@SdkInternalApi
44+
public final class UpdateExpressionResolver {
45+
46+
private final TableMetadata tableMetadata;
47+
private final Map<String, AttributeValue> nonKeyAttributes;
48+
private final UpdateExpression extensionExpression;
49+
private final UpdateExpression requestExpression;
50+
51+
private UpdateExpressionResolver(Builder builder) {
52+
this.tableMetadata = builder.tableMetadata;
53+
this.nonKeyAttributes = builder.nonKeyAttributes;
54+
this.extensionExpression = builder.extensionExpression;
55+
this.requestExpression = builder.requestExpression;
56+
}
57+
58+
public static Builder builder() {
59+
return new Builder();
60+
}
61+
62+
/**
63+
* Merges UpdateExpressions from three sources with priority: item attributes (lowest), extension expressions (medium),
64+
* request expressions (highest).
65+
*
66+
* <p><b>Steps:</b> Identify attributes used by extensions/requests to prevent REMOVE conflicts →
67+
* create item SET/REMOVE actions → merge extensions (override item) → merge request (override all).
68+
*
69+
* <p><b>Backward compatibility:</b> Without request expressions, behavior is identical to previous versions.
70+
* <p><b>Exceptions:</b> DynamoDbException may be thrown when the same attribute is updated by multiple sources.
71+
*
72+
* @return merged UpdateExpression, or empty if no updates needed
73+
*/
74+
public UpdateExpression resolve() {
75+
UpdateExpression itemExpression = null;
76+
77+
if (!nonKeyAttributes.isEmpty()) {
78+
Set<String> attributesExcludedFromRemoval = attributesPresentInOtherExpressions(
79+
Arrays.asList(extensionExpression, requestExpression));
80+
81+
itemExpression = UpdateExpression.mergeExpressions(
82+
generateItemSetExpression(nonKeyAttributes, tableMetadata),
83+
generateItemRemoveExpression(nonKeyAttributes, attributesExcludedFromRemoval));
84+
}
85+
86+
return Stream.of(itemExpression, extensionExpression, requestExpression)
87+
.filter(Objects::nonNull)
88+
.reduce(UpdateExpression::mergeExpressions)
89+
.orElse(null);
90+
}
91+
92+
private static Set<String> attributesPresentInOtherExpressions(Collection<UpdateExpression> updateExpressions) {
93+
return updateExpressions.stream()
94+
.filter(Objects::nonNull)
95+
.map(UpdateExpressionConverter::findAttributeNames)
96+
.flatMap(List::stream)
97+
.collect(Collectors.toSet());
98+
}
99+
100+
public static UpdateExpression generateItemSetExpression(Map<String, AttributeValue> itemMap,
101+
TableMetadata tableMetadata) {
102+
103+
Map<String, AttributeValue> setAttributes = filterMap(itemMap, e -> !isNullAttributeValue(e.getValue()));
104+
return UpdateExpression.builder()
105+
.actions(setActionsFor(setAttributes, tableMetadata))
106+
.build();
107+
}
108+
109+
public static UpdateExpression generateItemRemoveExpression(Map<String, AttributeValue> itemMap,
110+
Collection<String> nonRemoveAttributes) {
111+
Map<String, AttributeValue> removeAttributes =
112+
filterMap(itemMap, e -> isNullAttributeValue(e.getValue()) && !nonRemoveAttributes.contains(e.getKey()));
113+
114+
return UpdateExpression.builder()
115+
.actions(removeActionsFor(removeAttributes))
116+
.build();
117+
}
118+
119+
public static final class Builder {
120+
121+
private TableMetadata tableMetadata;
122+
private Map<String, AttributeValue> nonKeyAttributes;
123+
private UpdateExpression extensionExpression;
124+
private UpdateExpression requestExpression;
125+
126+
public Builder tableMetadata(TableMetadata tableMetadata) {
127+
this.tableMetadata = requireNonNull(
128+
tableMetadata, "A TableMetadata is required when generating an Update Expression");
129+
return this;
130+
}
131+
132+
public Builder nonKeyAttributes(Map<String, AttributeValue> nonKeyAttributes) {
133+
if (nonKeyAttributes == null) {
134+
this.nonKeyAttributes = Collections.emptyMap();
135+
} else {
136+
this.nonKeyAttributes = Collections.unmodifiableMap(new HashMap<>(nonKeyAttributes));
137+
}
138+
return this;
139+
}
140+
141+
public Builder extensionExpression(UpdateExpression extensionExpression) {
142+
this.extensionExpression = extensionExpression;
143+
return this;
144+
}
145+
146+
public Builder requestExpression(UpdateExpression requestExpression) {
147+
this.requestExpression = requestExpression;
148+
return this;
149+
}
150+
151+
public UpdateExpressionResolver build() {
152+
return new UpdateExpressionResolver(this);
153+
}
154+
155+
}
156+
}

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+
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+
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)