diff --git a/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-8a26648.json b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-8a26648.json new file mode 100644 index 000000000000..7a059a13a896 --- /dev/null +++ b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-8a26648.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "Amazon DynamoDB Enhanced Client", + "description": "Fix projection expression placeholders for list-index attributes in the enhanced DynamoDB client. Resolves [#7102](https://github.com/aws/aws-sdk-java-v2/issues/7102).", + "contributor": "Arnab" +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/ProjectionExpression.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/ProjectionExpression.java index e5d15fa13974..bec0fd38a7da 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/ProjectionExpression.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/ProjectionExpression.java @@ -93,7 +93,8 @@ public class ProjectionExpression { private static final String AMZN_MAPPED = "#AMZN_MAPPED_"; - private static final UnaryOperator PROJECTION_EXPRESSION_KEY_MAPPER = k -> AMZN_MAPPED + cleanAttributeName(k); + private static final UnaryOperator PROJECTION_EXPRESSION_KEY_MAPPER = + k -> AMZN_MAPPED + cleanAttributeName(attributeNameWithoutListDereference(k)); private final Optional projectionExpressionAsString; private final Map expressionAttributeNames; @@ -132,6 +133,7 @@ private static Map createAttributePlaceholders(List> placeholderToAttributeNames = nestedAttributeNames.stream() .flatMap(n -> n.elements().stream()) + .map(ProjectionExpression::attributeNameWithoutListDereference) .distinct() .collect(Collectors.groupingBy(PROJECTION_EXPRESSION_KEY_MAPPER, Collectors.toList())); @@ -180,8 +182,45 @@ private static String convertToNameExpression(NestedAttributeName nestedAttribut Map attributeToSanitizedMap) { return nestedAttributeName.elements() .stream() - .map(attributeToSanitizedMap::get) + .map(element -> attributeToSanitizedMap.get(attributeNameWithoutListDereference(element)) + + listDereferenceSuffix(element)) .collect(Collectors.joining(".")); } + private static String attributeNameWithoutListDereference(String attributeName) { + int firstListDereferenceIndex = firstListDereferenceIndex(attributeName); + return firstListDereferenceIndex == -1 ? attributeName : attributeName.substring(0, firstListDereferenceIndex); + } + + private static String listDereferenceSuffix(String attributeName) { + int firstListDereferenceIndex = firstListDereferenceIndex(attributeName); + return firstListDereferenceIndex == -1 ? "" : attributeName.substring(firstListDereferenceIndex); + } + + private static int firstListDereferenceIndex(String attributeName) { + int position = attributeName.length(); + int firstListDereferenceIndex = position; + + while (position > 0 && attributeName.charAt(position - 1) == ']') { + int openBracketIndex = attributeName.lastIndexOf('[', position - 1); + if (openBracketIndex == -1 || openBracketIndex == position - 1 || + !containsOnlyDigits(attributeName, openBracketIndex + 1, position - 1)) { + return -1; + } + firstListDereferenceIndex = openBracketIndex; + position = openBracketIndex; + } + + return firstListDereferenceIndex == attributeName.length() ? -1 : firstListDereferenceIndex; + } + + private static boolean containsOnlyDigits(String value, int beginIndex, int endIndex) { + for (int i = beginIndex; i < endIndex; i++) { + if (!Character.isDigit(value.charAt(i))) { + return false; + } + } + return true; + } + } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ProjectionExpressionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ProjectionExpressionTest.java index 968b7dfcedad..56bd646ba53b 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ProjectionExpressionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ProjectionExpressionTest.java @@ -22,7 +22,7 @@ public class ProjectionExpressionTest { static { EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_attribute", "attribute"); EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_Attribute", "Attribute"); - EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_firstiteminlist[0]", "firstiteminlist[0]"); + EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_firstiteminlist", "firstiteminlist"); EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_March_2021", "March-2021"); EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_Why_Make_This_An_Attribute_Name", "Why.Make-This*An:Attribute:Name"); } @@ -104,6 +104,23 @@ public void nonUniquePlaceholders_AreDisambiguated() { assertProjectionExpression(attributeNames, expectedAttributeNames, expectedProjectionExpression); } + @Test + public void listDereferenceAttributes_AreMappedToAttributeName() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_tags", "tags"); + expectedAttributeNames.put("#AMZN_MAPPED_metadata", "metadata"); + expectedAttributeNames.put("#AMZN_MAPPED_values", "values"); + + List attributeNames = Arrays.asList( + NestedAttributeName.create("tags[0]"), + NestedAttributeName.create("metadata", "values[1][2]") + ); + + String expectedProjectionExpression = "#AMZN_MAPPED_tags[0],#AMZN_MAPPED_metadata.#AMZN_MAPPED_values[1][2]"; + + assertProjectionExpression(attributeNames, expectedAttributeNames, expectedProjectionExpression); + } + private void assertProjectionExpression(List attributeNames, Map expectedAttributeNames, String expectedProjectionExpression) { diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/ScanOperationTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/ScanOperationTest.java index 661fb9be8915..a0f9dc7e2cf6 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/ScanOperationTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/ScanOperationTest.java @@ -229,6 +229,25 @@ public void generateRequest_projectionExpression() { assertThat(request, is(expectedRequest)); } + @Test + public void generateRequest_projectionExpressionWithListDereference() { + ScanOperation operation = ScanOperation.create( + ScanEnhancedRequest.builder() + .addAttributeToProject("tags[0]") + .build() + ); + ScanRequest request = operation.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + null); + + ScanRequest expectedRequest = ScanRequest.builder() + .tableName(TABLE_NAME) + .projectionExpression("#AMZN_MAPPED_tags[0]") + .expressionAttributeNames(singletonMap("#AMZN_MAPPED_tags", "tags")) + .build(); + assertThat(request, is(expectedRequest)); + } + @Test public void generateRequest_hashKeyOnly_exclusiveStartKey() { FakeItem exclusiveStartKey = createUniqueFakeItem();