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 java.util.ArrayList;
19 import java.util.Collections;
20 import java.util.List;
21 import java.util.Map;
22 import java.util.stream.Collectors;
23 import java.util.stream.Stream;
24 import software.amazon.awssdk.annotations.SdkInternalApi;
25 import software.amazon.awssdk.enhanced.dynamodb.Expression;
26 import software.amazon.awssdk.enhanced.dynamodb.update.AddAction;
27 import software.amazon.awssdk.enhanced.dynamodb.update.DeleteAction;
28 import software.amazon.awssdk.enhanced.dynamodb.update.RemoveAction;
29 import software.amazon.awssdk.enhanced.dynamodb.update.SetAction;
30 import software.amazon.awssdk.enhanced.dynamodb.update.UpdateExpression;
31 import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
32 
33 /**
34  *
35  * In order to convert it to the format that DynamoDB accepts, the toExpression() method will create an Expression
36  * with a coalesced string representation of its actions, and the ExpressionNames and ExpressionValues maps associated
37  * with all present actions.
38  *
39  * Note: Once an Expression has been obtained, you cannot combine it with another update Expression since they can't be
40  * reliably combined using a token.
41  *
42  *
43  * Validation
44  * When an UpdateExpression is created or merged with another, the code validates the integrity of the expression to ensure
45  * a successful database update.
46  * - The same attribute MAY NOT be chosen for updates in more than one action expression. This is checked by verifying that
47  * attribute only has one representation in the AttributeNames map.
48  * - The same attribute MAY NOT have more than one value. This is checked by verifying that attribute only has one
49  * representation in the AttributeValues map.
50  */
51 @SdkInternalApi
52 public final class UpdateExpressionConverter {
53 
54     private static final String REMOVE = "REMOVE ";
55     private static final String SET = "SET ";
56     private static final String DELETE = "DELETE ";
57     private static final String ADD = "ADD ";
58 
59     private static final String ACTION_SEPARATOR = ", ";
60     private static final String GROUP_SEPARATOR = " ";
61     private static final char DOT = '.';
62     private static final char LEFT_BRACKET = '[';
63 
UpdateExpressionConverter()64     private UpdateExpressionConverter() {
65     }
66 
67     /**
68      * Returns an {@link Expression} where all update actions in the UpdateExpression have been concatenated according
69      * to the rules of DDB Update Expressions, and all expression names and values have been combined into single maps,
70      * respectively.
71      *
72      * Observe that the resulting expression string should never be joined with another expression string, independently
73      * of whether it represents an update expression, conditional expression or another type of expression, since once
74      * the string is generated that update expression is the final format accepted by DDB.
75      *
76      * @return an Expression representing the concatenation of all actions in this UpdateExpression
77      */
toExpression(UpdateExpression expression)78     public static Expression toExpression(UpdateExpression expression) {
79         if (expression == null) {
80             return null;
81         }
82         Map<String, AttributeValue> expressionValues = mergeExpressionValues(expression);
83         Map<String, String> expressionNames = mergeExpressionNames(expression);
84         List<String> groupExpressions = groupExpressions(expression);
85 
86         return Expression.builder()
87                          .expression(String.join(GROUP_SEPARATOR, groupExpressions))
88                          .expressionNames(expressionNames)
89                          .expressionValues(expressionValues)
90                          .build();
91     }
92 
93     /**
94      * Attempts to find the list of attribute names that will be updated for the supplied {@link UpdateExpression} by looking at
95      * the combined collection of paths and ExpressionName values. Because attribute names can be composed from nested
96      * attribute references and list references, the leftmost part will be returned if composition is detected.
97      * <p>
98      * Examples: The expression contains a {@link DeleteAction} with a path value of 'MyAttribute[1]'; the list returned
99      * will have 'MyAttribute' as an element.}
100      *
101      * @return A list of top level attribute names that have update actions associated.
102      */
findAttributeNames(UpdateExpression updateExpression)103     public static List<String> findAttributeNames(UpdateExpression updateExpression) {
104         if (updateExpression == null) {
105             return Collections.emptyList();
106         }
107         List<String> attributeNames = listPathsWithoutTokens(updateExpression);
108         List<String> attributeNamesFromTokens = listAttributeNamesFromTokens(updateExpression);
109         attributeNames.addAll(attributeNamesFromTokens);
110         return attributeNames;
111     }
112 
groupExpressions(UpdateExpression expression)113     private static List<String> groupExpressions(UpdateExpression expression) {
114         List<String> groupExpressions = new ArrayList<>();
115         if (!expression.setActions().isEmpty()) {
116             groupExpressions.add(SET + expression.setActions().stream()
117                                                  .map(a -> String.format("%s = %s", a.path(), a.value()))
118                                                  .collect(Collectors.joining(ACTION_SEPARATOR)));
119         }
120         if (!expression.removeActions().isEmpty()) {
121             groupExpressions.add(REMOVE + expression.removeActions().stream()
122                                                     .map(RemoveAction::path)
123                                                     .collect(Collectors.joining(ACTION_SEPARATOR)));
124         }
125         if (!expression.deleteActions().isEmpty()) {
126             groupExpressions.add(DELETE + expression.deleteActions().stream()
127                                                     .map(a -> String.format("%s %s", a.path(), a.value()))
128                                                     .collect(Collectors.joining(ACTION_SEPARATOR)));
129         }
130         if (!expression.addActions().isEmpty()) {
131             groupExpressions.add(ADD + expression.addActions().stream()
132                                                  .map(a -> String.format("%s %s", a.path(), a.value()))
133                                                  .collect(Collectors.joining(ACTION_SEPARATOR)));
134         }
135         return groupExpressions;
136     }
137 
streamOfExpressionNames(UpdateExpression expression)138     private static Stream<Map<String, String>> streamOfExpressionNames(UpdateExpression expression) {
139         return Stream.concat(expression.setActions().stream().map(SetAction::expressionNames),
140                              Stream.concat(expression.removeActions().stream().map(RemoveAction::expressionNames),
141                                            Stream.concat(expression.deleteActions().stream()
142                                                                    .map(DeleteAction::expressionNames),
143                                                          expression.addActions().stream()
144                                                                    .map(AddAction::expressionNames))));
145     }
146 
mergeExpressionValues(UpdateExpression expression)147     private static Map<String, AttributeValue> mergeExpressionValues(UpdateExpression expression) {
148         return streamOfExpressionValues(expression)
149             .reduce(Expression::joinValues)
150             .orElseGet(Collections::emptyMap);
151     }
152 
streamOfExpressionValues(UpdateExpression expression)153     private static Stream<Map<String, AttributeValue>> streamOfExpressionValues(UpdateExpression expression) {
154         return Stream.concat(expression.setActions().stream().map(SetAction::expressionValues),
155                              Stream.concat(expression.deleteActions().stream().map(DeleteAction::expressionValues),
156                                            expression.addActions().stream().map(AddAction::expressionValues)));
157     }
158 
mergeExpressionNames(UpdateExpression expression)159     private static Map<String, String> mergeExpressionNames(UpdateExpression expression) {
160         return streamOfExpressionNames(expression)
161             .reduce(Expression::joinNames)
162             .orElseGet(Collections::emptyMap);
163     }
164 
listPathsWithoutTokens(UpdateExpression expression)165     private static List<String> listPathsWithoutTokens(UpdateExpression expression) {
166         return Stream.concat(expression.setActions().stream().map(SetAction::path),
167                              Stream.concat(expression.removeActions().stream().map(RemoveAction::path),
168                                            Stream.concat(expression.deleteActions().stream().map(DeleteAction::path),
169                                                          expression.addActions().stream().map(AddAction::path))))
170                      .map(UpdateExpressionConverter::removeNestingAndListReference)
171                      .filter(attributeName -> !attributeName.contains("#"))
172                      .collect(Collectors.toList());
173     }
174 
listAttributeNamesFromTokens(UpdateExpression updateExpression)175     private static List<String> listAttributeNamesFromTokens(UpdateExpression updateExpression) {
176         return mergeExpressionNames(updateExpression).values().stream()
177                                                      .map(UpdateExpressionConverter::removeNestingAndListReference)
178                                                      .collect(Collectors.toList());
179     }
180 
removeNestingAndListReference(String attributeName)181     private static String removeNestingAndListReference(String attributeName) {
182         return attributeName.substring(0, getRemovalIndex(attributeName));
183     }
184 
getRemovalIndex(String attributeName)185     private static int getRemovalIndex(String attributeName) {
186         for (int i = 0; i < attributeName.length(); i++) {
187             char c = attributeName.charAt(i);
188             if (c == DOT || c == LEFT_BRACKET) {
189                 return attributeName.indexOf(c);
190             }
191         }
192         return attributeName.length();
193     }
194 }
195