diff --git a/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-5603list.json b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-5603list.json new file mode 100644 index 000000000000..6f0315b744ea --- /dev/null +++ b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-5603list.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Fix projection expressions for nested paths containing list indexes so ExpressionAttributeNames map base attribute names and list dereferences remain in the projection expression. Resolves [#5603](https://github.com/aws/aws-sdk-java-v2/issues/5603)." +} 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..039243c0c837 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,6 +93,7 @@ public class ProjectionExpression { private static final String AMZN_MAPPED = "#AMZN_MAPPED_"; + // Placeholder keys are built from base attribute names only (list-index suffixes already removed). private static final UnaryOperator PROJECTION_EXPRESSION_KEY_MAPPER = k -> AMZN_MAPPED + cleanAttributeName(k); private final Optional projectionExpressionAsString; @@ -123,6 +124,10 @@ public Optional projectionExpressionAsString() { * If different original attribute names end up having the same placeholder name, a disambiguator is added to those * placeholders to make them unique and the number of map entries expand with the length of that list; however this is * a rare use-case and normally it's a 1:1 relation. + *

+ * Trailing list-dereference suffixes such as {@code [0]} or {@code [1][2]} are stripped before placeholder generation + * so ExpressionAttributeNames map only the base attribute name. The suffixes are re-attached when building the + * projection expression string. */ private static Map createAttributePlaceholders(List nestedAttributeNames) { if (CollectionUtils.isNullOrEmpty(nestedAttributeNames)) { @@ -132,6 +137,7 @@ private static Map createAttributePlaceholders(List> placeholderToAttributeNames = nestedAttributeNames.stream() .flatMap(n -> n.elements().stream()) + .map(element -> parseTrailingListDereferences(element).left()) .distinct() .collect(Collectors.groupingBy(PROJECTION_EXPRESSION_KEY_MAPPER, Collectors.toList())); @@ -180,8 +186,58 @@ private static String convertToNameExpression(NestedAttributeName nestedAttribut Map attributeToSanitizedMap) { return nestedAttributeName.elements() .stream() - .map(attributeToSanitizedMap::get) + .map(element -> { + // left = base attribute name; right = trailing list-index suffix (e.g. "[1][2]", or ""). + Pair baseNameAndSuffix = parseTrailingListDereferences(element); + return attributeToSanitizedMap.get(baseNameAndSuffix.left()) + + baseNameAndSuffix.right(); + }) .collect(Collectors.joining(".")); } + /** + * Parses a path element into its base attribute name and any trailing list-dereference suffix such as {@code [0]} + * or {@code [1][2]}. + * + * @return a {@link Pair} where {@link Pair#left()} is the base attribute name and {@link Pair#right()} is the + * list-dereference suffix (empty when the element has no trailing list indexes) + */ + private static Pair parseTrailingListDereferences(String attributeName) { + int suffixStart = trailingListDereferenceStart(attributeName); + if (suffixStart == -1) { + return Pair.of(attributeName, ""); + } + return Pair.of(attributeName.substring(0, suffixStart), attributeName.substring(suffixStart)); + } + + /** + * Finds the start index of a trailing sequence of list dereferences such as {@code [0]} or {@code [1][2]}. + * Returns -1 if the name does not end in a valid list-dereference suffix. + */ + private static int trailingListDereferenceStart(String attributeName) { + int position = attributeName.length(); + int suffixStart = 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; + } + suffixStart = openBracketIndex; + position = openBracketIndex; + } + + return suffixStart == attributeName.length() ? -1 : suffixStart; + } + + 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 beginIndex < endIndex; + } + } 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..934be371ec51 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 @@ -1,118 +1,216 @@ -package software.amazon.awssdk.enhanced.dynamodb; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; -import software.amazon.awssdk.enhanced.dynamodb.internal.ProjectionExpression; - -public class ProjectionExpressionTest { - - private static final String NESTING_SEPARATOR = "."; - private static final String PROJ_EXP_SEPARATOR = ","; - - private static final Map EXPECTED_ATTRIBUTE_NAMES = new HashMap<>(); - - 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_March_2021", "March-2021"); - EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_Why_Make_This_An_Attribute_Name", "Why.Make-This*An:Attribute:Name"); - } - - @Test - public void nullInputSetsStateCorrectly() { - ProjectionExpression projectionExpression = ProjectionExpression.create(null); - - assertThat(projectionExpression.expressionAttributeNames()).isEmpty(); - assertThat(projectionExpression.projectionExpressionAsString()).isEmpty(); - } - - @Test - public void emptyInputSetsStateCorrectly() { - ProjectionExpression projectionExpression = ProjectionExpression.create(new ArrayList<>()); - - assertThat(projectionExpression.expressionAttributeNames()).isEmpty(); - assertThat(projectionExpression.projectionExpressionAsString()).isEmpty(); - } - - @Test - public void severalTopLevelAttributes_handledCorrectly() { - List attributeNames = EXPECTED_ATTRIBUTE_NAMES.values() - .stream() - .map(NestedAttributeName::create) - .collect(Collectors.toList()); - - String expectedProjectionExpression = EXPECTED_ATTRIBUTE_NAMES.keySet() - .stream() - .collect(Collectors.joining(PROJ_EXP_SEPARATOR)); - - assertProjectionExpression(attributeNames, EXPECTED_ATTRIBUTE_NAMES, expectedProjectionExpression); - } - - @Test - public void severalNestedAttributes_handledCorrectly() { - List attributeNames = Arrays.asList(NestedAttributeName.create( - EXPECTED_ATTRIBUTE_NAMES.values() - .stream() - .collect(Collectors.toList()))); - - String expectedProjectionExpression = EXPECTED_ATTRIBUTE_NAMES.keySet() - .stream() - .collect(Collectors.joining(NESTING_SEPARATOR)); - - assertProjectionExpression(attributeNames, EXPECTED_ATTRIBUTE_NAMES, expectedProjectionExpression); - } - - @Test - public void nonUniqueAttributeNames_AreCollapsed() { - Map expectedAttributeNames = new HashMap<>(); - expectedAttributeNames.put("#AMZN_MAPPED_attribute", "attribute"); - - List attributeNames = Arrays.asList( - NestedAttributeName.create("attribute", "attribute"), - NestedAttributeName.create("attribute") - ); - - String expectedProjectionExpression = "#AMZN_MAPPED_attribute.#AMZN_MAPPED_attribute,#AMZN_MAPPED_attribute"; - - assertProjectionExpression(attributeNames, expectedAttributeNames, expectedProjectionExpression); - } - - @Test - public void nonUniquePlaceholders_AreDisambiguated() { - Map expectedAttributeNames = new HashMap<>(); - expectedAttributeNames.put("#AMZN_MAPPED_0_attribute_03", "attribute-03"); - expectedAttributeNames.put("#AMZN_MAPPED_1_attribute_03", "attribute.03"); - expectedAttributeNames.put("#AMZN_MAPPED_2_attribute_03", "attribute:03"); - - List attributeNames = Arrays.asList( - NestedAttributeName.create("attribute-03", "attribute.03"), - NestedAttributeName.create("attribute:03") - ); - - String expectedProjectionExpression = "#AMZN_MAPPED_0_attribute_03.#AMZN_MAPPED_1_attribute_03," - + "#AMZN_MAPPED_2_attribute_03"; - - assertProjectionExpression(attributeNames, expectedAttributeNames, expectedProjectionExpression); - } - - private void assertProjectionExpression(List attributeNames, - Map expectedAttributeNames, - String expectedProjectionExpression) { - ProjectionExpression projectionExpression = ProjectionExpression.create(attributeNames); - - Map expressionAttributeNames = projectionExpression.expressionAttributeNames(); - Optional projectionExpressionString = projectionExpression.projectionExpressionAsString(); - - assertThat(projectionExpressionString.get()).isEqualTo(expectedProjectionExpression); - assertThat(expressionAttributeNames).isEqualTo(expectedAttributeNames); - } -} +package software.amazon.awssdk.enhanced.dynamodb; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.ProjectionExpression; + +public class ProjectionExpressionTest { + + private static final String NESTING_SEPARATOR = "."; + private static final String PROJ_EXP_SEPARATOR = ","; + + private static final Map EXPECTED_ATTRIBUTE_NAMES = new HashMap<>(); + + static { + EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_attribute", "attribute"); + EXPECTED_ATTRIBUTE_NAMES.put("#AMZN_MAPPED_Attribute", "Attribute"); + 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"); + } + + @Test + public void nullInputSetsStateCorrectly() { + ProjectionExpression projectionExpression = ProjectionExpression.create(null); + + assertThat(projectionExpression.expressionAttributeNames()).isEmpty(); + assertThat(projectionExpression.projectionExpressionAsString()).isEmpty(); + } + + @Test + public void emptyInputSetsStateCorrectly() { + ProjectionExpression projectionExpression = ProjectionExpression.create(new ArrayList<>()); + + assertThat(projectionExpression.expressionAttributeNames()).isEmpty(); + assertThat(projectionExpression.projectionExpressionAsString()).isEmpty(); + } + + @Test + public void severalTopLevelAttributes_handledCorrectly() { + List attributeNames = EXPECTED_ATTRIBUTE_NAMES.values() + .stream() + .map(NestedAttributeName::create) + .collect(Collectors.toList()); + + String expectedProjectionExpression = String.join(PROJ_EXP_SEPARATOR, EXPECTED_ATTRIBUTE_NAMES.keySet()); + + assertProjectionExpression(attributeNames, EXPECTED_ATTRIBUTE_NAMES, expectedProjectionExpression); + } + + @Test + public void severalNestedAttributes_handledCorrectly() { + List attributeNames = Collections.singletonList(NestedAttributeName.create( + new ArrayList<>(EXPECTED_ATTRIBUTE_NAMES.values()))); + + String expectedProjectionExpression = String.join(NESTING_SEPARATOR, EXPECTED_ATTRIBUTE_NAMES.keySet()); + + assertProjectionExpression(attributeNames, EXPECTED_ATTRIBUTE_NAMES, expectedProjectionExpression); + } + + @Test + public void nonUniqueAttributeNames_AreCollapsed() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_attribute", "attribute"); + + List attributeNames = Arrays.asList( + NestedAttributeName.create("attribute", "attribute"), + NestedAttributeName.create("attribute") + ); + + String expectedProjectionExpression = "#AMZN_MAPPED_attribute.#AMZN_MAPPED_attribute,#AMZN_MAPPED_attribute"; + + assertProjectionExpression(attributeNames, expectedAttributeNames, expectedProjectionExpression); + } + + @Test + public void nonUniquePlaceholders_AreDisambiguated() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_0_attribute_03", "attribute-03"); + expectedAttributeNames.put("#AMZN_MAPPED_1_attribute_03", "attribute.03"); + expectedAttributeNames.put("#AMZN_MAPPED_2_attribute_03", "attribute:03"); + + List attributeNames = Arrays.asList( + NestedAttributeName.create("attribute-03", "attribute.03"), + NestedAttributeName.create("attribute:03") + ); + + String expectedProjectionExpression = "#AMZN_MAPPED_0_attribute_03.#AMZN_MAPPED_1_attribute_03," + + "#AMZN_MAPPED_2_attribute_03"; + + assertProjectionExpression(attributeNames, expectedAttributeNames, expectedProjectionExpression); + } + + @Test + public void listDereference_rootIndex_mapsBaseNameAndKeepsSuffixInExpression() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_tags", "tags"); + + assertProjectionExpression(Collections.singletonList(NestedAttributeName.create("tags[0]")), + expectedAttributeNames, + "#AMZN_MAPPED_tags[0]"); + } + + @Test + public void listDereference_nestedMapAndList_mapsBaseNamesAndKeepsSuffix() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_metadata", "metadata"); + expectedAttributeNames.put("#AMZN_MAPPED_values", "values"); + + assertProjectionExpression(Collections.singletonList(NestedAttributeName.create("metadata", "values[1]")), + expectedAttributeNames, + "#AMZN_MAPPED_metadata.#AMZN_MAPPED_values[1]"); + } + + @Test + public void listDereference_multipleIndexes_keepsRepeatedSuffix() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_values", "values"); + + assertProjectionExpression(Collections.singletonList(NestedAttributeName.create("values[1][2]")), + expectedAttributeNames, + "#AMZN_MAPPED_values[1][2]"); + } + + @Test + public void listDereference_listOfMapField_mapsBaseNamesAndKeepsSuffix() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_phoneNumbers", "phoneNumbers"); + expectedAttributeNames.put("#AMZN_MAPPED_type", "type"); + + assertProjectionExpression(Collections.singletonList(NestedAttributeName.create("phoneNumbers[1]", "type")), + expectedAttributeNames, + "#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type"); + } + + @Test + public void listDereference_specialCharacterBaseName_sanitizesPlaceholderOnly() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_March_2021", "March-2021"); + + assertProjectionExpression(Collections.singletonList(NestedAttributeName.create("March-2021[0]")), + expectedAttributeNames, + "#AMZN_MAPPED_March_2021[0]"); + } + + @Test + public void listDereference_reusesPlaceholderForSameBaseName() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_tags", "tags"); + + List attributeNames = Arrays.asList( + NestedAttributeName.create("tags"), + NestedAttributeName.create("tags[0]") + ); + + assertProjectionExpression(attributeNames, + expectedAttributeNames, + "#AMZN_MAPPED_tags,#AMZN_MAPPED_tags[0]"); + } + + @Test + public void listDereference_disambiguateCollidingBaseNames() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_0_attr_1", "attr-1"); + expectedAttributeNames.put("#AMZN_MAPPED_1_attr_1", "attr.1"); + + List attributeNames = Arrays.asList( + NestedAttributeName.create("attr-1[0]"), + NestedAttributeName.create("attr.1") + ); + + String expectedProjectionExpression = "#AMZN_MAPPED_0_attr_1[0],#AMZN_MAPPED_1_attr_1"; + + assertProjectionExpression(attributeNames, expectedAttributeNames, expectedProjectionExpression); + } + + @Test + public void listDereference_malformedNonDigitSuffix_isNotPeeled() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_foo[a]", "foo[a]"); + + assertProjectionExpression(Collections.singletonList(NestedAttributeName.create("foo[a]")), + expectedAttributeNames, + "#AMZN_MAPPED_foo[a]"); + } + + @Test + public void listDereference_malformedEmptyBrackets_isNotPeeled() { + Map expectedAttributeNames = new HashMap<>(); + expectedAttributeNames.put("#AMZN_MAPPED_foo[]", "foo[]"); + + assertProjectionExpression(Collections.singletonList(NestedAttributeName.create("foo[]")), + expectedAttributeNames, + "#AMZN_MAPPED_foo[]"); + } + + private void assertProjectionExpression(List attributeNames, + Map expectedAttributeNames, + String expectedProjectionExpression) { + ProjectionExpression projectionExpression = ProjectionExpression.create(attributeNames); + + Map expressionAttributeNames = projectionExpression.expressionAttributeNames(); + Optional projectionExpressionString = projectionExpression.projectionExpressionAsString(); + + assertThat(projectionExpressionString.get()).isEqualTo(expectedProjectionExpression); + assertThat(expressionAttributeNames).isEqualTo(expectedAttributeNames); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AsyncListProjectionExpressionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AsyncListProjectionExpressionTest.java new file mode 100644 index 000000000000..d01380a2bfc5 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AsyncListProjectionExpressionTest.java @@ -0,0 +1,117 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static software.amazon.awssdk.enhanced.dynamodb.AttributeConverterProvider.defaultProvider; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.core.async.SdkPublisher; +import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.NestedAttributeName; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; + +/** + * Async smoke coverage for list-index projection expressions against DynamoDB Local (#5603). + */ +public class AsyncListProjectionExpressionTest extends LocalDynamoDbAsyncTestBase { + + private final String tableName = getConcreteTableName("async-list-projection-table"); + private DynamoDbEnhancedAsyncClient enhancedClient; + private DynamoDbAsyncTable table; + + @Before + public void createTable() { + enhancedClient = DynamoDbEnhancedAsyncClient.builder() + .dynamoDbClient(getDynamoDbAsyncClient()) + .build(); + TableSchema tableSchema = + TableSchema.documentSchemaBuilder() + .attributeConverterProviders(defaultProvider()) + .addIndexPartitionKey(TableMetadata.primaryIndexName(), "id", AttributeValueType.S) + .build(); + table = enhancedClient.table(tableName, tableSchema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())).join(); + + Map homePhone = new HashMap<>(); + homePhone.put("type", "home"); + homePhone.put("number", "111"); + + Map cellPhone = new HashMap<>(); + cellPhone.put("type", "cell"); + cellPhone.put("number", "222"); + + EnhancedDocument person = EnhancedDocument.builder() + .attributeConverterProviders(defaultProvider()) + .putString("id", "2") + .putString("firstName", "FirstName2") + .putString("lastName", "LastName2") + .putString("extraSibling", "omit-me") + .putList("phoneNumbers", + Arrays.asList(homePhone, cellPhone), + EnhancedType.mapOf(String.class, String.class)) + .build(); + table.putItem(person).join(); + } + + @After + public void deleteTable() { + getDynamoDbAsyncClient().deleteTable(DeleteTableRequest.builder().tableName(tableName).build()).join(); + } + + @Test + public void scan_listIndexProjection_returnsCompactedPartialList() { + ScanEnhancedRequest enhancedRequest = + ScanEnhancedRequest.builder() + .attributesToProject("id", "firstName", "lastName") + .addNestedAttributeToProject(NestedAttributeName.create("phoneNumbers[1]", "type")) + .build(); + + SdkPublisher> publisher = table.scan(enhancedRequest); + List> pages = drainPublisher(publisher, 1); + assertThat(pages.get(0).items(), hasSize(1)); + + EnhancedDocument result = pages.get(0).items().get(0); + assertThat(result.getString("id"), is("2")); + assertThat(result.getString("firstName"), is("FirstName2")); + assertThat(result.getString("lastName"), is("LastName2")); + assertThat(result.getString("extraSibling"), is(nullValue())); + + List phoneNumbers = result.getListOfUnknownType("phoneNumbers"); + assertThat(phoneNumbers, hasSize(1)); + Map selectedPhone = phoneNumbers.get(0).m(); + assertThat(selectedPhone.get("type").s(), is("cell")); + assertThat(selectedPhone.containsKey("number"), is(false)); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/ListProjectionExpressionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/ListProjectionExpressionTest.java new file mode 100644 index 000000000000..d7ca256d21b5 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/ListProjectionExpressionTest.java @@ -0,0 +1,192 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.enhanced.dynamodb.functionaltests; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static software.amazon.awssdk.enhanced.dynamodb.AttributeConverterProvider.defaultProvider; +import static software.amazon.awssdk.enhanced.dynamodb.internal.AttributeValues.stringValue; +import static software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional.keyEqualTo; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.NestedAttributeName; +import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.DefaultOperationContext; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.QueryOperation; +import software.amazon.awssdk.enhanced.dynamodb.internal.operations.ScanOperation; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; + +/** + * Functional coverage for list-index projection expressions against DynamoDB Local (#5603). + */ +public class ListProjectionExpressionTest extends LocalDynamoDbSyncTestBase { + + private final String tableName = getConcreteTableName("list-projection-table"); + private DynamoDbEnhancedClient enhancedClient; + private DynamoDbTable table; + private TableSchema tableSchema; + + @Before + public void createTable() { + enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(getDynamoDbClient()) + .build(); + tableSchema = TableSchema.documentSchemaBuilder() + .attributeConverterProviders(defaultProvider()) + .addIndexPartitionKey(TableMetadata.primaryIndexName(), "id", AttributeValueType.S) + .build(); + table = enhancedClient.table(tableName, tableSchema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + seedPersonDocument(); + } + + @After + public void deleteTable() { + getDynamoDbClient().deleteTable(DeleteTableRequest.builder().tableName(tableName).build()); + } + + @Test + public void scan_filterAndListIndexProjection_returnsCompactedPartialList() { + Expression filterExpression = Expression.builder() + .expression("phoneNumbers[1].#type = :type") + .putExpressionName("#type", "type") + .putExpressionValue(":type", stringValue("cell")) + .build(); + + ScanEnhancedRequest enhancedRequest = ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .attributesToProject("id", "firstName", "lastName") + .addNestedAttributeToProject( + NestedAttributeName.create("phoneNumbers[1]", "type")) + .build(); + + ScanRequest lowLevelRequest = ScanOperation.create(enhancedRequest) + .generateRequest(tableSchema, + DefaultOperationContext.create( + tableName, TableMetadata.primaryIndexName()), + null); + + assertThat(lowLevelRequest.filterExpression(), is("phoneNumbers[1].#type = :type")); + assertThat(lowLevelRequest.projectionExpression(), + is("#AMZN_MAPPED_id,#AMZN_MAPPED_firstName,#AMZN_MAPPED_lastName," + + "#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#type", "type")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_id", "id")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_firstName", "firstName")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_lastName", "lastName")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_phoneNumbers", "phoneNumbers")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_type", "type")); + + Iterator> results = table.scan(enhancedRequest).iterator(); + assertThat(results.hasNext(), is(true)); + Page page = results.next(); + assertThat(results.hasNext(), is(false)); + assertThat(page.items(), hasSize(1)); + + assertProjectedPerson(page.items().get(0)); + } + + @Test + public void query_listIndexProjection_returnsCompactedPartialList() { + QueryEnhancedRequest enhancedRequest = QueryEnhancedRequest.builder() + .queryConditional(keyEqualTo(k -> k.partitionValue("2"))) + .attributesToProject("id", "firstName", "lastName") + .addNestedAttributeToProject( + NestedAttributeName.create("phoneNumbers[1]", "type")) + .build(); + + QueryRequest lowLevelRequest = QueryOperation.create(enhancedRequest) + .generateRequest(tableSchema, + DefaultOperationContext.create( + tableName, TableMetadata.primaryIndexName()), + null); + + assertThat(lowLevelRequest.projectionExpression(), + is("#AMZN_MAPPED_id,#AMZN_MAPPED_firstName,#AMZN_MAPPED_lastName," + + "#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_id", "id")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_firstName", "firstName")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_lastName", "lastName")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_phoneNumbers", "phoneNumbers")); + assertThat(lowLevelRequest.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_type", "type")); + + Iterator> results = table.query(enhancedRequest).iterator(); + assertThat(results.hasNext(), is(true)); + Page page = results.next(); + assertThat(results.hasNext(), is(false)); + assertThat(page.items(), hasSize(1)); + + assertProjectedPerson(page.items().get(0)); + } + + private void seedPersonDocument() { + Map homePhone = new HashMap<>(); + homePhone.put("type", "home"); + homePhone.put("number", "111"); + + Map cellPhone = new HashMap<>(); + cellPhone.put("type", "cell"); + cellPhone.put("number", "222"); + + EnhancedDocument person = EnhancedDocument.builder() + .attributeConverterProviders(defaultProvider()) + .putString("id", "2") + .putString("firstName", "FirstName2") + .putString("lastName", "LastName2") + .putString("extraSibling", "omit-me") + .putList("phoneNumbers", + Arrays.asList(homePhone, cellPhone), + EnhancedType.mapOf(String.class, String.class)) + .build(); + table.putItem(person); + } + + private void assertProjectedPerson(EnhancedDocument result) { + assertThat(result.getString("id"), is("2")); + assertThat(result.getString("firstName"), is("FirstName2")); + assertThat(result.getString("lastName"), is("LastName2")); + assertThat(result.getString("extraSibling"), is(nullValue())); + + List phoneNumbers = result.getListOfUnknownType("phoneNumbers"); + assertThat(phoneNumbers, hasSize(1)); + Map selectedPhone = phoneNumbers.get(0).m(); + assertThat(selectedPhone.get("type").s(), is("cell")); + assertThat(selectedPhone.containsKey("number"), is(false)); + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/QueryOperationTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/QueryOperationTest.java index 731dff1ae033..6b221960f131 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/QueryOperationTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/operations/QueryOperationTest.java @@ -16,6 +16,7 @@ package software.amazon.awssdk.enhanced.dynamodb.internal.operations; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static java.util.Collections.singletonMap; import static java.util.stream.Collectors.toList; import static org.mockito.ArgumentMatchers.any; @@ -50,6 +51,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.NestedAttributeName; import software.amazon.awssdk.enhanced.dynamodb.OperationContext; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; import software.amazon.awssdk.enhanced.dynamodb.extensions.ReadModification; @@ -297,6 +299,99 @@ public void generateRequest_projectionExpression() { assertThat(queryRequest.expressionAttributeNames().get("#AMZN_MAPPED_version")).isEqualTo("version"); } + @Test + public void generateRequest_projectionExpressionWithListDereference() { + QueryOperation queryToTest = + QueryOperation.create(QueryEnhancedRequest.builder() + .queryConditional(keyEqualTo(k -> k.partitionValue(keyItem.getId()))) + .addAttributeToProject("tags[0]") + .build()); + QueryRequest queryRequest = queryToTest.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + null); + + assertThat(queryRequest.projectionExpression()).isEqualTo("#AMZN_MAPPED_tags[0]"); + assertThat(queryRequest.expressionAttributeNames().get("#AMZN_MAPPED_tags")).isEqualTo("tags"); + } + + @Test + public void generateRequest_filterAndListProjection_mergesExpressionNames() { + Expression filterExpression = Expression.builder() + .expression("#status = :status") + .putExpressionName("#status", "status") + .putExpressionValue(":status", stringValue("active")) + .build(); + + QueryOperation queryToTest = + QueryOperation.create(QueryEnhancedRequest.builder() + .queryConditional(keyEqualTo(k -> k.partitionValue(keyItem.getId()))) + .filterExpression(filterExpression) + .addNestedAttributeToProject( + NestedAttributeName.create("phoneNumbers[1]", "type")) + .build()); + QueryRequest queryRequest = queryToTest.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + null); + + assertThat(queryRequest.filterExpression()).isEqualTo("#status = :status"); + assertThat(queryRequest.projectionExpression()) + .isEqualTo("#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type"); + assertThat(queryRequest.expressionAttributeNames().get("#status")).isEqualTo("status"); + assertThat(queryRequest.expressionAttributeNames().get("#AMZN_MAPPED_phoneNumbers")).isEqualTo("phoneNumbers"); + assertThat(queryRequest.expressionAttributeNames().get("#AMZN_MAPPED_type")).isEqualTo("type"); + assertThat(queryRequest.expressionAttributeValues().get(":status")).isEqualTo(stringValue("active")); + } + + @Test + public void generateRequest_filterAndListProjection_sameAttributeCompatibleAliases() { + Expression filterExpression = Expression.builder() + .expression("#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type = :type") + .putExpressionName("#AMZN_MAPPED_phoneNumbers", "phoneNumbers") + .putExpressionName("#AMZN_MAPPED_type", "type") + .putExpressionValue(":type", stringValue("cell")) + .build(); + + QueryOperation queryToTest = + QueryOperation.create(QueryEnhancedRequest.builder() + .queryConditional(keyEqualTo(k -> k.partitionValue(keyItem.getId()))) + .filterExpression(filterExpression) + .addNestedAttributeToProject( + NestedAttributeName.create("phoneNumbers[1]", "type")) + .build()); + QueryRequest queryRequest = queryToTest.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + null); + + assertThat(queryRequest.filterExpression()) + .isEqualTo("#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type = :type"); + assertThat(queryRequest.projectionExpression()) + .isEqualTo("#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type"); + assertThat(queryRequest.expressionAttributeNames().get("#AMZN_MAPPED_phoneNumbers")).isEqualTo("phoneNumbers"); + assertThat(queryRequest.expressionAttributeNames().get("#AMZN_MAPPED_type")).isEqualTo("type"); + assertThat(queryRequest.expressionAttributeValues().get(":type")).isEqualTo(stringValue("cell")); + } + + @Test + public void generateRequest_filterAndListProjection_conflictingAliases_throws() { + Expression filterExpression = Expression.builder() + .expression("#AMZN_MAPPED_phoneNumbers = :value") + .putExpressionName("#AMZN_MAPPED_phoneNumbers", "otherAttr") + .putExpressionValue(":value", stringValue("x")) + .build(); + + QueryOperation queryToTest = + QueryOperation.create(QueryEnhancedRequest.builder() + .queryConditional(keyEqualTo(k -> k.partitionValue(keyItem.getId()))) + .filterExpression(filterExpression) + .addNestedAttributeToProject( + NestedAttributeName.create("phoneNumbers[1]", "type")) + .build()); + + assertThatThrownBy(() -> queryToTest.generateRequest(FakeItem.getTableSchema(), PRIMARY_CONTEXT, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("#AMZN_MAPPED_phoneNumbers"); + } + @Test public void generateRequest_hashKeyOnly_withExclusiveStartKey() { FakeItem exclusiveStartKey = createUniqueFakeItem(); 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..f745f994456d 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 @@ -21,6 +21,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasEntry; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -46,6 +47,7 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.NestedAttributeName; import software.amazon.awssdk.enhanced.dynamodb.OperationContext; import software.amazon.awssdk.enhanced.dynamodb.TableMetadata; import software.amazon.awssdk.enhanced.dynamodb.extensions.ReadModification; @@ -229,6 +231,103 @@ 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_filterAndListProjection_mergesExpressionNames() { + Expression filterExpression = Expression.builder() + .expression("#status = :status") + .putExpressionName("#status", "status") + .putExpressionValue(":status", stringValue("active")) + .build(); + + ScanOperation operation = ScanOperation.create( + ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .addNestedAttributeToProject(NestedAttributeName.create("phoneNumbers[1]", "type")) + .build() + ); + ScanRequest request = operation.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + null); + + assertThat(request.filterExpression(), is("#status = :status")); + assertThat(request.projectionExpression(), + is("#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type")); + assertThat(request.expressionAttributeNames(), hasEntry("#status", "status")); + assertThat(request.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_phoneNumbers", "phoneNumbers")); + assertThat(request.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_type", "type")); + assertThat(request.expressionAttributeValues(), hasEntry(":status", stringValue("active"))); + } + + @Test + public void generateRequest_filterAndListProjection_sameAttributeCompatibleAliases() { + Expression filterExpression = Expression.builder() + .expression("#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type = :type") + .putExpressionName("#AMZN_MAPPED_phoneNumbers", "phoneNumbers") + .putExpressionName("#AMZN_MAPPED_type", "type") + .putExpressionValue(":type", stringValue("cell")) + .build(); + + ScanOperation operation = ScanOperation.create( + ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .addNestedAttributeToProject(NestedAttributeName.create("phoneNumbers[1]", "type")) + .build() + ); + ScanRequest request = operation.generateRequest(FakeItem.getTableSchema(), + PRIMARY_CONTEXT, + null); + + assertThat(request.filterExpression(), + is("#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type = :type")); + assertThat(request.projectionExpression(), + is("#AMZN_MAPPED_phoneNumbers[1].#AMZN_MAPPED_type")); + assertThat(request.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_phoneNumbers", "phoneNumbers")); + assertThat(request.expressionAttributeNames(), hasEntry("#AMZN_MAPPED_type", "type")); + assertThat(request.expressionAttributeValues(), hasEntry(":type", stringValue("cell"))); + } + + @Test + public void generateRequest_filterAndListProjection_conflictingAliases_throws() { + Expression filterExpression = Expression.builder() + .expression("#AMZN_MAPPED_phoneNumbers = :value") + .putExpressionName("#AMZN_MAPPED_phoneNumbers", "otherAttr") + .putExpressionValue(":value", stringValue("x")) + .build(); + + ScanOperation operation = ScanOperation.create( + ScanEnhancedRequest.builder() + .filterExpression(filterExpression) + .addNestedAttributeToProject(NestedAttributeName.create("phoneNumbers[1]", "type")) + .build() + ); + + try { + operation.generateRequest(FakeItem.getTableSchema(), PRIMARY_CONTEXT, null); + fail("Expected IllegalArgumentException for conflicting expression names"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage().contains("#AMZN_MAPPED_phoneNumbers"), is(true)); + } + } + @Test public void generateRequest_hashKeyOnly_exclusiveStartKey() { FakeItem exclusiveStartKey = createUniqueFakeItem();