Skip to content

Commit 6eb75be

Browse files
committed
Fixes a bug where nested attributes in projection expressions aren't sanitized
1 parent c7d5302 commit 6eb75be

25 files changed

+601
-380
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"category": "DynamoDB Enhanced Client",
3+
"contributor": "",
4+
"type": "bugfix",
5+
"description": "Fixes a bug in issue [#2310](https://github.com/aws/aws-sdk-java-v2/issues/2310) where nested attributes aren't sanitized properly for projection expressions."
6+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,11 @@ public static Map<String, AttributeValue> joinValues(Map<String, AttributeValue>
152152
*/
153153
public static Map<String, String> joinNames(Map<String, String> expressionNames1,
154154
Map<String, String> expressionNames2) {
155-
if (expressionNames1 == null) {
155+
if (expressionNames1 == null || expressionNames1.isEmpty()) {
156156
return expressionNames2;
157157
}
158158

159-
if (expressionNames2 == null) {
159+
if (expressionNames2 == null || expressionNames2.isEmpty()) {
160160
return expressionNames1;
161161
}
162162

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

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,30 @@
1919
import java.util.Arrays;
2020
import java.util.Collections;
2121
import java.util.List;
22+
import java.util.stream.Collectors;
2223
import software.amazon.awssdk.annotations.SdkPublicApi;
24+
import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest;
25+
import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest;
2326
import software.amazon.awssdk.utils.Validate;
2427

2528
/**
26-
* High-level representation of a DynamoDB 'NestedAttributeName' that can be used in various situations where the API requires
27-
* or accepts an Nested Attribute Name.
28-
* Simple Attribute Name can be represented by passing just the name of the attribute.
29-
* Nested Attributes are represented by List of String where each index of list corresponds to Nesting level Names.
30-
* <p> While using attributeToProject in {@link software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest}
31-
* and {@link software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest} we need way to represent Nested Attributes.
32-
* The normal DOT(.) separator is not recognized as a Nesting level separator by DynamoDB request,
33-
* thus we need to use NestedAttributeName
34-
* which can be used to represent Nested attributes.
35-
* <p> Example : NestedAttributeName.create("foo") corresponds to a NestedAttributeName with elements list
36-
* with single element foo which represents Simple attribute name "foo" without nesting.
37-
* <p>NestedAttributeName.create("foo", "bar") corresponds to a NestedAttributeName with elements list "foo", "bar"
38-
* respresenting nested attribute name "foo.bar".
29+
* A high-level representation of a DynamoDB nested attribute name that can be used in various situations where the API requires
30+
* or accepts a nested attribute name. The nested attributes are represented by a list of strings where each element
31+
* corresponds to a nesting level. A simple (top-level) attribute name can be represented by creating an instance with a
32+
* single string element.
33+
* <p>
34+
* NestedAttributeName is used directly in {@link QueryEnhancedRequest#nestedAttributesToProject()}
35+
* and {@link ScanEnhancedRequest#nestedAttributesToProject()}, and indirectly by
36+
* {@link QueryEnhancedRequest#attributesToProject()} and {@link ScanEnhancedRequest#attributesToProject()}.
37+
* <p>
38+
* Examples of creating NestedAttributeNames:
39+
* <ul>
40+
* <li>Simple attribute {@code Level0} can be created as {@code NestedAttributeName.create("Level0")}</li>
41+
* <li>Nested attribute {@code Level0.Level1} can be created as {@code NestedAttributeName.create("Level0", "Level1")}</li>
42+
* <li>Nested attribute {@code Level0.Level-2} can be created as {@code NestedAttributeName.create("Level0", "Level-2")}</li>
43+
* <li>List item 0 of {@code ListAttribute} can be created as {@code NestedAttributeName.create("ListAttribute[0]")}
44+
* </li>
45+
* </ul>
3946
*/
4047
@SdkPublicApi
4148
public final class NestedAttributeName {
@@ -132,6 +139,11 @@ public int hashCode() {
132139
return elements != null ? elements.hashCode() : 0;
133140
}
134141

142+
@Override
143+
public String toString() {
144+
return elements == null ? "" : elements.stream().collect(Collectors.joining("."));
145+
}
146+
135147
/**
136148
* A builder for {@link NestedAttributeName}.
137149
*/
@@ -227,7 +239,6 @@ public Builder elements(List<String> elements) {
227239
return this;
228240
}
229241

230-
231242
public NestedAttributeName build() {
232243
return new NestedAttributeName(elements);
233244
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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;
17+
18+
import static software.amazon.awssdk.enhanced.dynamodb.internal.EnhancedClientUtils.cleanAttributeName;
19+
20+
import java.util.Collections;
21+
import java.util.HashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Optional;
25+
import java.util.function.UnaryOperator;
26+
import java.util.stream.Collectors;
27+
import java.util.stream.IntStream;
28+
import java.util.stream.Stream;
29+
import software.amazon.awssdk.annotations.SdkInternalApi;
30+
import software.amazon.awssdk.enhanced.dynamodb.NestedAttributeName;
31+
import software.amazon.awssdk.utils.CollectionUtils;
32+
import software.amazon.awssdk.utils.Pair;
33+
34+
/**
35+
* This class represents the concept of a projection expression, which allows the user to specify which specific attributes
36+
* should be returned when a table is queried. By default, all attribute names in a projection expression are replaced with
37+
* a cleaned placeholder version of itself, prefixed with <i>#AMZN_MAPPED</i>.
38+
* <p>
39+
* A ProjectionExpression can return a correctly formatted projection expression string
40+
* containing placeholder names (see {@link #projectionExpressionAsString()}), as well as the expression attribute names map which
41+
* contains the mapping from the placeholder attribute name to the actual attribute name (see
42+
* {@link #expressionAttributeNames()}).
43+
* <p>
44+
* <b>Resolving duplicates</b>
45+
* <ul>
46+
* <li>If the input to the ProjectionExpression contains the same attribute name in more than one place, independent of
47+
* nesting level, it will be mapped to a single placeholder</li>
48+
* <li>If two attributes resolves to the same placeholder name, a disambiguator is added to the placeholder in order to
49+
* make it unique.</li>
50+
* </ul>
51+
* <p>
52+
* <b>Placeholder conversion examples</b>
53+
* <ul>
54+
* <li>'MyAttribute' maps to {@code #AMZN_MAPPED_MyAttribute}</li>
55+
* <li>'MyAttribute' appears twice in input but maps to only one entry {@code #AMZN_MAPPED_MyAttribute}.</li>
56+
* <li>'MyAttribute-1' maps to {@code #AMZN_MAPPED_MyAttribute_1}</li>
57+
* <li>'MyAttribute-1' and 'MyAttribute.1' in the same input maps to {@code #AMZN_MAPPED_0_MyAttribute_1} and
58+
* {@code #AMZN_MAPPED_1_MyAttribute_1}</li>
59+
* </ul>
60+
* <b>Projection expression usage example</b>
61+
* <pre>
62+
* {@code
63+
* List<NestedAttributeName> attributeNames = Arrays.asList(
64+
* NestedAttributeName.create("MyAttribute")
65+
* NestedAttributeName.create("MyAttribute.WithDot", "MyAttribute.03"),
66+
* NestedAttributeName.create("MyAttribute:03, "MyAttribute")
67+
* );
68+
* ProjectionExpression projectionExpression = ProjectionExpression.create(attributeNames);
69+
* Map<String, String> expressionAttributeNames = projectionExpression.expressionAttributeNames();
70+
* Optional<String> projectionExpressionString = projectionExpression.projectionExpressionAsString();
71+
* }
72+
*
73+
* results in
74+
*
75+
* expressionAttributeNames: {
76+
* #AMZN_MAPPED_MyAttribute : MyAttribute,
77+
* #AMZN_MAPPED_MyAttribute_WithDot : MyAttribute.WithDot}
78+
* #AMZN_MAPPED_0_MyAttribute_03 : MyAttribute.03}
79+
* #AMZN_MAPPED_1_MyAttribute_03 : MyAttribute:03}
80+
* }
81+
* and
82+
*
83+
* projectionExpressionString: "#AMZN_MAPPED_MyAttribute,#AMZN_MAPPED_MyAttribute_WithDot.#AMZN_MAPPED_0_MyAttribute_03,
84+
* #AMZN_MAPPED_1_MyAttribute_03.#AMZN_MAPPED_MyAttribute"
85+
* </pre>
86+
* <p>
87+
* For more information, see <a href=
88+
* "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ProjectionExpressions.html"
89+
* >Projection Expressions</a> in the <i>Amazon DynamoDB Developer Guide</i>.
90+
* </p>
91+
*/
92+
@SdkInternalApi
93+
public class ProjectionExpression {
94+
95+
private static final String AMZN_MAPPED = "#AMZN_MAPPED_";
96+
private static final UnaryOperator<String> PROJECTION_EXPRESSION_KEY_MAPPER = k -> AMZN_MAPPED + cleanAttributeName(k);
97+
98+
private final Optional<String> projectionExpressionAsString;
99+
private final Map<String, String> expressionAttributeNames;
100+
101+
private ProjectionExpression(List<NestedAttributeName> nestedAttributeNames) {
102+
this.expressionAttributeNames = createAttributePlaceholders(nestedAttributeNames);
103+
this.projectionExpressionAsString = buildProjectionExpression(nestedAttributeNames, this.expressionAttributeNames);
104+
}
105+
106+
public static ProjectionExpression create(List<NestedAttributeName> nestedAttributeNames) {
107+
return new ProjectionExpression(nestedAttributeNames);
108+
}
109+
110+
public Map<String, String> expressionAttributeNames() {
111+
return this.expressionAttributeNames;
112+
}
113+
114+
public Optional<String> projectionExpressionAsString() {
115+
return this.projectionExpressionAsString;
116+
}
117+
118+
/**
119+
* Creates a map of modified attribute/placeholder name -> real attribute name based on what is essentially a list of list of
120+
* attribute names. Duplicates are removed from the list of attribute names and then the names are transformed
121+
* into DDB-compatible 'placeholders' using the supplied function, resulting in a
122+
* map of placeholder name -> list of original attribute names that resolved to that placeholder.
123+
* If different original attribute names end up having the same placeholder name, a disambiguator is added to those
124+
* placeholders to make them unique and the number of map entries expand with the length of that list; however this is
125+
* a rare use-case and normally it's a 1:1 relation.
126+
*/
127+
private static Map<String, String> createAttributePlaceholders(List<NestedAttributeName> nestedAttributeNames) {
128+
if (CollectionUtils.isNullOrEmpty(nestedAttributeNames)) {
129+
return new HashMap<>();
130+
}
131+
132+
Map<String, List<String>> placeholderToAttributeNames =
133+
nestedAttributeNames.stream()
134+
.flatMap(n -> n.elements().stream())
135+
.distinct()
136+
.collect(Collectors.groupingBy(PROJECTION_EXPRESSION_KEY_MAPPER, Collectors.toList()));
137+
138+
return Collections.unmodifiableMap(
139+
placeholderToAttributeNames.entrySet()
140+
.stream()
141+
.flatMap(entry -> disambiguateNonUniquePlaceholderNames(entry.getKey(),
142+
entry.getValue()))
143+
.collect(Collectors.toMap(Pair::left, Pair::right)));
144+
}
145+
146+
private static Stream<Pair<String, String>> disambiguateNonUniquePlaceholderNames(String placeholder, List<String> values) {
147+
if (values.size() == 1) {
148+
return Stream.of(Pair.of(placeholder, values.get(0)));
149+
}
150+
return IntStream.range(0, values.size())
151+
.mapToObj(index -> Pair.of(addDisambiguator(placeholder, index), values.get(index)));
152+
}
153+
154+
private static String addDisambiguator(String placeholder, int index) {
155+
return AMZN_MAPPED + index + "_" + placeholder.substring(AMZN_MAPPED.length());
156+
}
157+
158+
/**
159+
* The projection expression contains only placeholder names, and is based on the list if nested attribute names, which
160+
* are converted into string representations with each attribute name replaced by its placeholder name as specified
161+
* in the expressionAttributeNames map. Because we need to find the placeholder value of an attribute, the
162+
* expressionAttributeNames map must be reversed before doing a lookup.
163+
*/
164+
private static Optional<String> buildProjectionExpression(List<NestedAttributeName> nestedAttributeNames,
165+
Map<String, String> expressionAttributeNames) {
166+
if (CollectionUtils.isNullOrEmpty(nestedAttributeNames)) {
167+
return Optional.empty();
168+
}
169+
170+
Map<String, String> attributeToPlaceholderNames = CollectionUtils.inverseMap(expressionAttributeNames);
171+
172+
return Optional.of(nestedAttributeNames.stream()
173+
.map(attributeName -> convertToNameExpression(attributeName,
174+
attributeToPlaceholderNames))
175+
.distinct()
176+
.collect(Collectors.joining(",")));
177+
}
178+
179+
private static String convertToNameExpression(NestedAttributeName nestedAttributeName,
180+
Map<String, String> attributeToSanitizedMap) {
181+
return nestedAttributeName.elements()
182+
.stream()
183+
.map(attributeToSanitizedMap::get)
184+
.collect(Collectors.joining("."));
185+
}
186+
187+
}

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

Lines changed: 0 additions & 107 deletions
This file was deleted.

0 commit comments

Comments
 (0)