Skip to content

Commit 7ae6ed3

Browse files
authored
Fixed EnhancedClient UpdateItem operation to make it work on nested attributes as well (#5593)
* Fixed EnhancedClient UpdateItem operation to make it work on nested attributes as well * Add Tests for multi-level nesting updates to work correctly * Fixed PR feedback * Updated Javadocs * Addressed Pr feedback * Removed indendation changes * Added tests for FlattenedMapper * fixed indendation * Fix indendation and remove unintentional changes * Configured MappingConfiguration object * Added methods to AttributeMapping interface * Fixed unintentional indendation changes * Fixed unintentional indendation changes * Add changelogs * Introduce a new method to transform input to be able to perform update operations on nested DynamoDB object attributes. * Remove unwanted changes * Indent * Remove unwanted changes * Added testing * Added test to validate updating string to null * Remove unintentional indentation changes * Fix checkstyle * Update test case to add empty nested attribute * Added a test to verify that updates to non-scalar nested attributes with ignoreNulls set to true, throws DDBException * Uncomment test assertions * Ensure correct workings of updating to an emoty map * Fixed checkstyle * Addressed pr feedback * Created modes of update operation and deprecated existing ignoreNulls configuration * Added more comments around deprecation * Grouped validation methods * Added more documentation to define the modes of update operations * Removed MAPS_ONLY mode and added more documentation * Added unit test * Improved documentation to establish tradeoffs between update modes * Modified error code in documentation * Updated Documentation around update modes
1 parent db81c49 commit 7ae6ed3

File tree

12 files changed

+723
-8
lines changed

12 files changed

+723
-8
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "bugfix",
3+
"category": "AWS SDK for Java v2",
4+
"contributor": "anirudh9391",
5+
"description": "Introduce a new method to transform input to be able to perform update operations on nested DynamoDB object attributes."
6+
}

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@
1515

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

18+
import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE;
19+
1820
import java.util.Collections;
1921
import java.util.List;
2022
import java.util.Map;
2123
import java.util.Optional;
2224
import java.util.Set;
2325
import java.util.function.Function;
2426
import java.util.function.Supplier;
27+
import java.util.regex.Pattern;
2528
import java.util.stream.Collectors;
2629
import java.util.stream.Stream;
2730
import software.amazon.awssdk.annotations.SdkInternalApi;
@@ -40,6 +43,7 @@ public final class EnhancedClientUtils {
4043
private static final Set<Character> SPECIAL_CHARACTERS = Stream.of(
4144
'*', '.', '-', '#', '+', ':', '/', '(', ')', ' ',
4245
'&', '<', '>', '?', '=', '!', '@', '%', '$', '|').collect(Collectors.toSet());
46+
private static final Pattern NESTED_OBJECT_PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE);
4347

4448
private EnhancedClientUtils() {
4549

@@ -67,18 +71,30 @@ public static String cleanAttributeName(String key) {
6771
return somethingChanged ? new String(chars) : key;
6872
}
6973

74+
private static boolean isNestedAttribute(String key) {
75+
return key.contains(NESTED_OBJECT_UPDATE);
76+
}
77+
7078
/**
7179
* Creates a key token to be used with an ExpressionNames map.
7280
*/
7381
public static String keyRef(String key) {
74-
return "#AMZN_MAPPED_" + cleanAttributeName(key);
82+
String cleanAttributeName = cleanAttributeName(key);
83+
cleanAttributeName = isNestedAttribute(cleanAttributeName) ?
84+
NESTED_OBJECT_PATTERN.matcher(cleanAttributeName).replaceAll(".#AMZN_MAPPED_")
85+
: cleanAttributeName;
86+
return "#AMZN_MAPPED_" + cleanAttributeName;
7587
}
7688

7789
/**
7890
* Creates a value token to be used with an ExpressionValues map.
7991
*/
8092
public static String valueRef(String value) {
81-
return ":AMZN_MAPPED_" + cleanAttributeName(value);
93+
String cleanAttributeName = cleanAttributeName(value);
94+
cleanAttributeName = isNestedAttribute(cleanAttributeName) ?
95+
NESTED_OBJECT_PATTERN.matcher(cleanAttributeName).replaceAll("_")
96+
: cleanAttributeName;
97+
return ":AMZN_MAPPED_" + cleanAttributeName;
8298
}
8399

84100
public static <T> T readAndTransformSingleItem(Map<String, AttributeValue> itemMap,

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

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

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

18+
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue;
1819
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.readAndTransformSingleItem;
1920
import static software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionUtils.operationExpression;
2021
import static software.amazon.awssdk.utils.CollectionUtils.filterMap;
2122

2223
import java.util.Collection;
24+
import java.util.HashMap;
2325
import java.util.List;
2426
import java.util.Map;
2527
import java.util.Optional;
@@ -34,6 +36,7 @@
3436
import software.amazon.awssdk.enhanced.dynamodb.extensions.WriteModification;
3537
import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.DefaultDynamoDbExtensionContext;
3638
import software.amazon.awssdk.enhanced.dynamodb.internal.update.UpdateExpressionConverter;
39+
import software.amazon.awssdk.enhanced.dynamodb.model.IgnoreNullsMode;
3740
import software.amazon.awssdk.enhanced.dynamodb.model.TransactUpdateItemEnhancedRequest;
3841
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest;
3942
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse;
@@ -53,7 +56,8 @@
5356
public class UpdateItemOperation<T>
5457
implements TableOperation<T, UpdateItemRequest, UpdateItemResponse, UpdateItemEnhancedResponse<T>>,
5558
TransactableWriteOperation<T> {
56-
59+
60+
public static final String NESTED_OBJECT_UPDATE = "_NESTED_ATTR_UPDATE_";
5761
private final Either<UpdateItemEnhancedRequest<T>, TransactUpdateItemEnhancedRequest<T>> request;
5862

5963
private UpdateItemOperation(UpdateItemEnhancedRequest<T> request) {
@@ -90,7 +94,22 @@ public UpdateItemRequest generateRequest(TableSchema<T> tableSchema,
9094
r -> Optional.ofNullable(r.ignoreNulls()))
9195
.orElse(null);
9296

93-
Map<String, AttributeValue> itemMap = tableSchema.itemToMap(item, Boolean.TRUE.equals(ignoreNulls));
97+
IgnoreNullsMode ignoreNullsMode = request.map(r -> Optional.ofNullable(r.ignoreNullsMode()),
98+
r -> Optional.ofNullable(r.ignoreNullsMode()))
99+
.orElse(IgnoreNullsMode.DEFAULT);
100+
101+
if (ignoreNullsMode == IgnoreNullsMode.SCALAR_ONLY
102+
|| ignoreNullsMode == IgnoreNullsMode.MAPS_ONLY) {
103+
ignoreNulls = true;
104+
}
105+
Map<String, AttributeValue> itemMapImmutable = tableSchema.itemToMap(item, Boolean.TRUE.equals(ignoreNulls));
106+
107+
// If ignoreNulls is set to true, check for nested params to be updated
108+
// If needed, Transform itemMap for it to be able to handle them.
109+
110+
Map<String, AttributeValue> itemMap = ignoreNullsMode == IgnoreNullsMode.SCALAR_ONLY ?
111+
transformItemToMapForUpdateExpression(itemMapImmutable) : itemMapImmutable;
112+
94113
TableMetadata tableMetadata = tableSchema.tableMetadata();
95114

96115
WriteModification transformation =
@@ -141,6 +160,58 @@ public UpdateItemRequest generateRequest(TableSchema<T> tableSchema,
141160

142161
return requestBuilder.build();
143162
}
163+
164+
/**
165+
* Method checks if a nested object parameter requires an update
166+
* If so flattens out nested params separated by "_NESTED_ATTR_UPDATE_"
167+
* this is consumed by @link EnhancedClientUtils to form the appropriate UpdateExpression
168+
*/
169+
public Map<String, AttributeValue> transformItemToMapForUpdateExpression(Map<String, AttributeValue> itemToMap) {
170+
171+
Map<String, AttributeValue> nestedAttributes = new HashMap<>();
172+
173+
itemToMap.forEach((key, value) -> {
174+
if (value.hasM() && isNotEmptyMap(value.m())) {
175+
nestedAttributes.put(key, value);
176+
}
177+
});
178+
179+
if (!nestedAttributes.isEmpty()) {
180+
Map<String, AttributeValue> itemToMapMutable = new HashMap<>(itemToMap);
181+
nestedAttributes.forEach((key, value) -> {
182+
itemToMapMutable.remove(key);
183+
nestedItemToMap(itemToMapMutable, key, value);
184+
});
185+
return itemToMapMutable;
186+
}
187+
188+
return itemToMap;
189+
}
190+
191+
private Map<String, AttributeValue> nestedItemToMap(Map<String, AttributeValue> itemToMap,
192+
String key,
193+
AttributeValue attributeValue) {
194+
attributeValue.m().forEach((mapKey, mapValue) -> {
195+
String nestedAttributeKey = key + NESTED_OBJECT_UPDATE + mapKey;
196+
if (attributeValueNonNullOrShouldWriteNull(mapValue)) {
197+
if (mapValue.hasM()) {
198+
nestedItemToMap(itemToMap, nestedAttributeKey, mapValue);
199+
} else {
200+
itemToMap.put(nestedAttributeKey, mapValue);
201+
}
202+
}
203+
});
204+
return itemToMap;
205+
}
206+
207+
private boolean isNotEmptyMap(Map<String, AttributeValue> map) {
208+
return !map.isEmpty() && map.values().stream()
209+
.anyMatch(this::attributeValueNonNullOrShouldWriteNull);
210+
}
211+
212+
private boolean attributeValueNonNullOrShouldWriteNull(AttributeValue attributeValue) {
213+
return !isNullAttributeValue(attributeValue);
214+
}
144215

145216
@Override
146217
public UpdateItemEnhancedResponse<T> transformResponse(UpdateItemResponse response,

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@
1818
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.isNullAttributeValue;
1919
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.keyRef;
2020
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.valueRef;
21+
import static software.amazon.awssdk.enhanced.dynamodb.internal.operations.UpdateItemOperation.NESTED_OBJECT_UPDATE;
2122
import static software.amazon.awssdk.utils.CollectionUtils.filterMap;
2223

2324
import java.util.Arrays;
2425
import java.util.Collections;
2526
import java.util.List;
2627
import java.util.Map;
2728
import java.util.function.Function;
29+
import java.util.regex.Pattern;
2830
import java.util.stream.Collectors;
2931
import software.amazon.awssdk.annotations.SdkInternalApi;
3032
import software.amazon.awssdk.enhanced.dynamodb.TableMetadata;
@@ -39,6 +41,8 @@
3941
@SdkInternalApi
4042
public final class UpdateExpressionUtils {
4143

44+
private static final Pattern PATTERN = Pattern.compile(NESTED_OBJECT_UPDATE);
45+
4246
private UpdateExpressionUtils() {
4347
}
4448

@@ -136,9 +140,12 @@ private static Function<String, String> behaviorBasedValue(UpdateBehavior update
136140
/**
137141
* Simple utility method that can create an ExpressionNames map based on a list of attribute names.
138142
*/
139-
private static Map<String, String> expressionNamesFor(String... attributeNames) {
140-
return Arrays.stream(attributeNames)
141-
.collect(Collectors.toMap(EnhancedClientUtils::keyRef, Function.identity()));
142-
}
143+
private static Map<String, String> expressionNamesFor(String attributeNames) {
144+
if (attributeNames.contains(NESTED_OBJECT_UPDATE)) {
145+
return Arrays.stream(PATTERN.split(attributeNames)).distinct()
146+
.collect(Collectors.toMap(EnhancedClientUtils::keyRef, Function.identity()));
147+
}
143148

149+
return Collections.singletonMap(keyRef(attributeNames), attributeNames);
150+
}
144151
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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.model;
17+
18+
import software.amazon.awssdk.annotations.SdkPublicApi;
19+
20+
/**
21+
* <p>
22+
* The SCALAR_ONLY mode supports updates to scalar attributes to any level (top level, first nested level, second nested level,
23+
* etc.) when the user wants to update scalar attributes by providing only the delta of changes to be updated. This mode
24+
* does not support updates to maps and is expected to throw a 4xx DynamoDB exception if done so.
25+
* <p>
26+
* In the MAPS_ONLY mode, creation of new map/bean structures through update statements are supported, i.e. setting
27+
* null/non-existent maps to non-null values. If users try to update scalar attributes in this mode, it will overwrite
28+
* existing values in the table.
29+
* <p>
30+
* The DEFAULT mode disables any special handling around null values in the update query expression
31+
*/
32+
@SdkPublicApi
33+
public enum IgnoreNullsMode {
34+
SCALAR_ONLY,
35+
MAPS_ONLY,
36+
DEFAULT
37+
}

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/model/TransactUpdateItemEnhancedRequest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ public class TransactUpdateItemEnhancedRequest<T> {
4040

4141
private final T item;
4242
private final Boolean ignoreNulls;
43+
private final IgnoreNullsMode ignoreNullsMode;
4344
private final Expression conditionExpression;
4445
private final String returnValuesOnConditionCheckFailure;
4546

4647
private TransactUpdateItemEnhancedRequest(Builder<T> builder) {
4748
this.item = builder.item;
4849
this.ignoreNulls = builder.ignoreNulls;
50+
this.ignoreNullsMode = builder.ignoreNullsMode;
4951
this.conditionExpression = builder.conditionExpression;
5052
this.returnValuesOnConditionCheckFailure = builder.returnValuesOnConditionCheckFailure;
5153
}
@@ -67,6 +69,7 @@ public static <T> Builder<T> builder(Class<? extends T> itemClass) {
6769
public Builder<T> toBuilder() {
6870
return new Builder<T>().item(item)
6971
.ignoreNulls(ignoreNulls)
72+
.ignoreNullsMode(ignoreNullsMode)
7073
.conditionExpression(conditionExpression)
7174
.returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure);
7275
}
@@ -80,11 +83,20 @@ public T item() {
8083

8184
/**
8285
* Returns if the update operation should ignore attributes with null values, or false if it has not been set.
86+
* This is deprecated in favour of ignoreNullsMode()
8387
*/
88+
@Deprecated
8489
public Boolean ignoreNulls() {
8590
return ignoreNulls;
8691
}
8792

93+
/**
94+
* Returns the mode of update to be performed
95+
*/
96+
public IgnoreNullsMode ignoreNullsMode() {
97+
return ignoreNullsMode;
98+
}
99+
88100
/**
89101
* Returns the condition {@link Expression} set on this request object, or null if it doesn't exist.
90102
*/
@@ -161,6 +173,7 @@ public int hashCode() {
161173
public static final class Builder<T> {
162174
private T item;
163175
private Boolean ignoreNulls;
176+
private IgnoreNullsMode ignoreNullsMode;
164177
private Expression conditionExpression;
165178
private String returnValuesOnConditionCheckFailure;
166179

@@ -178,11 +191,17 @@ private Builder() {
178191
* @param ignoreNulls the boolean value
179192
* @return a builder of this type
180193
*/
194+
@Deprecated
181195
public Builder<T> ignoreNulls(Boolean ignoreNulls) {
182196
this.ignoreNulls = ignoreNulls;
183197
return this;
184198
}
185199

200+
public Builder<T> ignoreNullsMode(IgnoreNullsMode ignoreNullsMode) {
201+
this.ignoreNullsMode = ignoreNullsMode;
202+
return this;
203+
}
204+
186205
/**
187206
* Defines a logical expression on an item's attribute values which, if evaluating to true,
188207
* will allow the update operation to succeed. If evaluating to false, the operation will not succeed.

0 commit comments

Comments
 (0)