diff --git a/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-8fb589b.json b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-8fb589b.json new file mode 100644 index 000000000000..9af1fa1a25db --- /dev/null +++ b/.changes/next-release/feature-AmazonDynamoDBEnhancedClient-8fb589b.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Improve missing converter error handling in DynamoDB Enhanced Client" +} diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProvider.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProvider.java index 88cfbe39e82f..04f7431191ca 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProvider.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProvider.java @@ -72,6 +72,7 @@ import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ZoneIdAttributeConverter; import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ZoneOffsetAttributeConverter; import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ZonedDateTimeAsStringAttributeConverter; +import software.amazon.awssdk.utils.CollectionUtils; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.Validate; @@ -157,17 +158,22 @@ private Optional> findConverterInternal(EnhancedType innerType = (EnhancedType) type.rawClassParameters().get(0); - AttributeConverter innerConverter = findConverter(innerType) - .orElseThrow(() -> new IllegalStateException("Converter not found for " + type)); - return Optional.of((AttributeConverter) ListAttributeConverter.create(innerConverter)); - } else if (type.rawClass().isEnum()) { - return Optional.of(EnumAttributeConverter.create(((EnhancedType) type).rawClass())); + if (!Object.class.equals(type.rawClass())) { + if (type.rawClass().isAssignableFrom(Map.class)) { + requireTypeParameters(type); + converter = createMapConverter(type); + } else if (type.rawClass().isAssignableFrom(Set.class)) { + requireTypeParameters(type); + converter = createSetConverter(type); + } else if (type.rawClass().isAssignableFrom(List.class)) { + requireTypeParameters(type); + EnhancedType innerType = (EnhancedType) type.rawClassParameters().get(0); + AttributeConverter innerConverter = findConverter(innerType) + .orElseThrow(() -> new IllegalStateException("Converter not found for " + type)); + return Optional.of((AttributeConverter) ListAttributeConverter.create(innerConverter)); + } else if (type.rawClass().isEnum()) { + return Optional.of(EnumAttributeConverter.create(((EnhancedType) type).rawClass())); + } } if (type.tableSchema().isPresent()) { @@ -186,6 +192,12 @@ private boolean shouldCache(Class type) { return !type.isAnonymousClass(); } + private static void requireTypeParameters(EnhancedType type) { + if (CollectionUtils.isNullOrEmpty(type.rawClassParameters())) { + throw new IllegalStateException("Converter not found for " + type + ". Type parameters are required for this type."); + } + } + @SuppressWarnings("unchecked") private AttributeConverter createMapConverter(EnhancedType type) { EnhancedType keyType = type.rawClassParameters().get(0); diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/document/DefaultEnhancedDocument.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/document/DefaultEnhancedDocument.java index 8a9eb68f58f3..739825a17dfb 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/document/DefaultEnhancedDocument.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/document/DefaultEnhancedDocument.java @@ -45,6 +45,7 @@ import software.amazon.awssdk.protocols.jsoncore.JsonNode; import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.utils.CollectionUtils; import software.amazon.awssdk.utils.Lazy; import software.amazon.awssdk.utils.StringUtils; import software.amazon.awssdk.utils.Validate; @@ -89,15 +90,22 @@ public static Builder builder() { public static AttributeConverter converterForClass(EnhancedType type, ChainConverterProvider chainConverterProvider) { - if (type.rawClass().isAssignableFrom(List.class)) { - return (AttributeConverter) ListAttributeConverter - .create(converterForClass(type.rawClassParameters().get(0), chainConverterProvider)); - } - if (type.rawClass().isAssignableFrom(Map.class)) { - return (AttributeConverter) MapAttributeConverter.mapConverter( - StringConverterProvider.defaultProvider().converterFor(type.rawClassParameters().get(0)), - converterForClass(type.rawClassParameters().get(1), chainConverterProvider)); + if (!Object.class.equals(type.rawClass())) { + if (type.rawClass().isAssignableFrom(List.class)) { + requireTypeParameters(type); + return (AttributeConverter) ListAttributeConverter + .create(converterForClass(type.rawClassParameters().get(0), chainConverterProvider)); + } + if (type.rawClass().isAssignableFrom(Map.class)) { + requireTypeParameters(type); + return (AttributeConverter) MapAttributeConverter.mapConverter( + StringConverterProvider.defaultProvider().converterFor(type.rawClassParameters().get(0)), + converterForClass(type.rawClassParameters().get(1), chainConverterProvider)); + } } + // TODO: what about the case when type.rawClass() is Object or Unsupported type + // and we have overridden the default converter provider with a custom one that can / cannot handle this type? + // We should check for that case here. return Optional.ofNullable(chainConverterProvider.converterFor(type)) .orElseThrow(() -> new IllegalStateException( "AttributeConverter not found for class " + type @@ -496,4 +504,10 @@ private static void checkAndValidateClass(Class type, boolean isPut) { String.format(VALIDATE_TYPE_ERROR, "Map", isPut ? "put" : "get", "Map")); } + + private static void requireTypeParameters(EnhancedType type) { + if (CollectionUtils.isNullOrEmpty(type.rawClassParameters())) { + throw new IllegalStateException("Converter not found for " + type + ". Type parameters are required for this type."); + } + } } \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/BeanSchemaConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/BeanSchemaConverterTest.java new file mode 100644 index 000000000000..d2348f44749e --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/BeanSchemaConverterTest.java @@ -0,0 +1,1252 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DocumentAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.ImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests converter selection and item conversion for bean based table schemas. + *

+ * The tests cover scalar, enumeration, collection, map, and nested bean attributes. They also verify null handling, + * schema caching, declared converter providers, attribute converters, provider precedence, and unsupported attribute + * types during schema creation and item conversion. + */ +public class BeanSchemaConverterTest { + + @BeforeEach + void setUp() { + clearSchemaCache(BeanTableSchema.class); + clearSchemaCache(ImmutableTableSchema.class); + RecordingCustomProvider.reset(); + ReturningNullProvider.reset(); + ThrowingProvider.reset(); + ObjectProvider.reset(); + UnsupportedTypeOnlyProvider.reset(); + } + + private static void clearSchemaCache(Class schemaClass) { + try { + Method method = schemaClass.getDeclaredMethod("clearSchemaCache"); + method.setAccessible(true); + method.invoke(null); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + @Test + @DisplayName("A nonnull string property selects StringAttributeConverter and round-trips") + void fromBean_whenNonnullString_selectsStringConverterAndRoundTrips() { + TableSchema schema = TableSchema.fromBean(StringBean.class); + StringBean model = new StringBean(); + model.setId("id-1"); + model.setValue("hello"); + + Map map = schema.itemToMap(model, true); + StringBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(StringAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("hello"); + assertThat(read.getValue()).isEqualTo("hello"); + } + + @Test + @DisplayName("A string list with a duplicate is stored as L and read as an ArrayList") + void fromBean_whenStringListWithDuplicate_preservesOrderAndDuplicateAsArrayList() { + TableSchema schema = TableSchema.fromBean(StringListBean.class); + StringListBean model = new StringListBean(); + model.setId("id-1"); + List input = new ArrayList<>(); + input.add("a"); + input.add("b"); + input.add("a"); + model.setValue(input); + + Map map = schema.itemToMap(model, true); + StringListBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(read.getValue()).isInstanceOf(ArrayList.class) + .containsExactly("a", "b", "a"); + } + + @Test + @DisplayName("A nested bean property selects DocumentAttributeConverter and is reconstructed") + void fromBean_whenNestedBean_selectsDocumentConverterAndReconstructsNestedValue() { + TableSchema schema = TableSchema.fromBean(NestedOuterBean.class); + NestedInnerBean nested = new NestedInnerBean(); + nested.setNestedValue("inner"); + NestedOuterBean model = new NestedOuterBean(); + model.setId("id-1"); + model.setValue(nested); + + Map map = schema.itemToMap(model, true); + NestedOuterBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(DocumentAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(read.getValue().getNestedValue()).isEqualTo("inner"); + } + + @Test + @DisplayName("itemToMap with ignoreNulls false stores a null string as DynamoDB NULL") + void itemToMap_whenNullStringIgnoreNullsFalse_includesNulValue() { + TableSchema schema = TableSchema.fromBean(StringBean.class); + StringBean model = new StringBean(); + model.setId("id-1"); + + Map map = schema.itemToMap(model, false); + + assertThat(map).containsEntry("value", AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("itemToMap with ignoreNulls true omits a null string property") + void itemToMap_whenNullStringIgnoreNullsTrue_omitsValue() { + TableSchema schema = TableSchema.fromBean(StringBean.class); + StringBean model = new StringBean(); + model.setId("id-1"); + + Map map = schema.itemToMap(model, true); + + assertThat(map).doesNotContainKey("value"); + } + + @Test + @DisplayName("mapToItem skips the setter for a DynamoDB NULL string property") + void mapToItem_whenNulString_doesNotCallSetterAndLeavesValueNull() { + TableSchema schema = TableSchema.fromBean(NullReadBean.class); + Map map = new HashMap<>(); + map.put("id", AttributeValue.fromS("id-1")); + map.put("value", AttributeValue.fromNul(true)); + + NullReadBean read = schema.mapToItem(map); + + assertThat(read).isNotNull(); + assertThat(read.valueSetterCalls).isZero(); + assertThat(read.getValue()).isNull(); + } + + @Test + @DisplayName("An unconverted Object property fails converter lookup") + void fromBean_whenUnconvertedObject_throwsConverterNotFound() { + assertThatThrownBy(() -> TableSchema.fromBean(ObjectBean.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("An unconverted UnsupportedType property fails converter lookup") + void fromBean_whenUnconvertedUnsupportedType_throwsIllegalStateException() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + assertThatThrownBy(() -> TableSchema.fromBean(UnsupportedBean.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A custom provider listed first is consulted twice and supplies CustomTypeConverter") + void fromBean_whenCustomProviderFirst_invokesCustomTwiceAndRoundTrips() { + TableSchema schema = TableSchema.fromBean(CustomFirstBean.class); + CustomFirstBean model = new CustomFirstBean(); + CustomType input = new CustomType("x"); + model.setValue(input); + + Map map = schema.itemToMap(model, true); + CustomFirstBean read = schema.mapToItem(map); + + assertThat(RecordingCustomProvider.current().requestedTypes()) + .hasSize(2) + .containsExactly(EnhancedType.of(CustomType.class), EnhancedType.of(CustomType.class)); + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(CustomTypeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.getValue()).isEqualTo(input); + } + + @Test + @DisplayName("A null-returning provider is consulted once then the default string converter is used") + void fromBean_whenNullReturningProviderThenDefault_invokesProviderOnceAndSelectsStringConverter() { + TableSchema schema = TableSchema.fromBean(NullThenDefaultBean.class); + + assertThat(ReturningNullProvider.current().requestedTypes()) + .containsExactly(EnhancedType.of(String.class)); + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(StringAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + } + + @Test + @DisplayName("Default-first ordering throws before a later custom provider is consulted") + void fromBean_whenDefaultProviderFirst_throwsAndDoesNotInvokeCustomProvider() { + EnhancedType type = EnhancedType.of(CustomType.class); + + assertThatThrownBy(() -> TableSchema.fromBean(DefaultThenCustomBean.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + RecordingCustomProvider custom = RecordingCustomProvider.current(); + assertThat(custom == null || custom.requestedTypes().isEmpty()).isTrue(); + } + + @Test + @DisplayName("A throwing provider failure is propagated without consulting the default provider") + void fromBean_whenThrowingProviderThenDefault_propagatesProviderFailure() { + EnhancedType type = EnhancedType.of(CustomType.class); + + assertThatThrownBy(() -> TableSchema.fromBean(ThrowingThenDefaultBean.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute converter provider failed while looking up " + type); + } + + @Test + @DisplayName("Bean schema factory caches by model class") + void fromBean_whenSameClassTwice_returnsSameBeanTableSchemaReference() { + BeanTableSchema first = TableSchema.fromBean(StringBean.class); + BeanTableSchema second = TableSchema.fromBean(StringBean.class); + + assertThat(first).isSameAs(second); + assertThat(first).isInstanceOf(BeanTableSchema.class); + } + + @Test + @DisplayName("Generic schema factory dispatches a bean class") + void fromClass_whenBeanModel_returnsBeanTableSchemaWithStringConverter() { + StringBean item = new StringBean(); + item.setId("id-1"); + item.setValue("text"); + + TableSchema schema = TableSchema.fromClass(StringBean.class); + Map map = schema.itemToMap(item, true); + + assertThat(schema).isInstanceOf(BeanTableSchema.class); + assertThat(schema.converterForAttribute("value")).isInstanceOf(StringAttributeConverter.class); + assertThat(map.get("value").s()).isEqualTo("text"); + } + + @Test + @DisplayName("Bean ObjectProvider before default selects ObjectStringConverter") + void fromBean_whenObjectProviderBeforeDefault_selectsObjectStringConverter() { + ObjectProviderBean item = new ObjectProviderBean(); + item.setValue(new Object()); + + TableSchema schema = TableSchema.fromBean(ObjectProviderBean.class); + Map map = schema.itemToMap(item, true); + ObjectProviderBean read = schema.mapToItem(map); + + int objectRequestCount = 0; + for (EnhancedType requestedType : ObjectProvider.current().requestedTypes()) { + if (EnhancedType.of(Object.class).equals(requestedType)) { + objectRequestCount++; + } + } + assertThat(objectRequestCount).isEqualTo(2); + assertThat(schema.converterForAttribute("value")).isInstanceOf(ObjectStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.getValue()).isEqualTo("custom"); + } + + @Test + @DisplayName("ConvertedBy intercepts Object and does not consult the schema provider for that type") + void fromBean_whenObjectConvertedBy_selectsObjectStringConverterAndSkipsProvider() { + TableSchema schema = TableSchema.fromBean(ConvertedObjectBean.class); + ConvertedObjectBean model = new ConvertedObjectBean(); + model.setValue(new Object()); + + Map map = schema.itemToMap(model, true); + ConvertedObjectBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(ObjectStringConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(ObjectProvider.current().requestedTypes()).doesNotContain(EnhancedType.of(Object.class)); + assertThat(read.getValue()).isEqualTo("custom"); + } + + @Test + @DisplayName("ConvertedBy intercepts UnsupportedType with UnsupportedStringConverter") + void fromBean_whenUnsupportedConvertedBy_selectsUnsupportedStringConverterAndRoundTrips() { + TableSchema schema = TableSchema.fromBean(ConvertedUnsupportedBean.class); + ConvertedUnsupportedBean model = new ConvertedUnsupportedBean(); + UnsupportedType input = new UnsupportedType(); + model.setValue(input); + + Map map = schema.itemToMap(model, true); + ConvertedUnsupportedBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(UnsupportedStringConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.getValue()).isEqualTo(input); + } + + @Test + @DisplayName("ConvertedBy intercepts ArrayList and the setter receives an ArrayList") + void fromBean_whenArrayListConvertedBy_selectsArrayListConverterAndSetterReceivesArrayList() { + TableSchema schema = TableSchema.fromBean(ConvertedArrayListBean.class); + ConvertedArrayListBean model = new ConvertedArrayListBean(); + ArrayList input = new ArrayList<>(); + input.add("a"); + input.add("b"); + model.setValue(input); + + Map map = schema.itemToMap(model, true); + ConvertedArrayListBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(ArrayListConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(map.get("value").hasL()).isTrue(); + assertThat(read.getValue()).isInstanceOf(ArrayList.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("An empty provider list fails with NPE when no attribute converter is present") + void fromBean_whenEmptyProvidersWithoutConvertedBy_throwsNullPointerException() { + assertThatThrownBy(() -> TableSchema.fromBean(EmptyProvidersBean.class)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("ConvertedBy still works when the bean declares an empty provider list") + void fromBean_whenEmptyProvidersWithConvertedBy_usesCustomStringConverter() { + TableSchema schema = + TableSchema.fromBean(EmptyProvidersConvertedBean.class); + EmptyProvidersConvertedBean model = new EmptyProvidersConvertedBean(); + model.setValue("text"); + + Map map = schema.itemToMap(model, true); + EmptyProvidersConvertedBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(CustomStringConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("custom:text"); + assertThat(read.getValue()).isEqualTo("text"); + } + + @Test + @DisplayName("An unconverted ArrayList property fails lookup before mapToItem") + void fromBean_whenUnconvertedArrayList_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> TableSchema.fromBean(ArrayListBean.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A custom provider is not consulted for list members during default fallback") + void fromBean_whenUnsupportedListWithCustomMemberProvider_throwsForEnclosingListType() { + EnhancedType> type = EnhancedType.listOf(UnsupportedType.class); + + assertThatThrownBy(() -> TableSchema.fromBean(UnsupportedListBean.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(UnsupportedTypeOnlyProvider.current().requestedTypes()).hasSize(1); + EnhancedType requestedType = UnsupportedTypeOnlyProvider.current().requestedTypes().get(0); + assertThat(requestedType.rawClass()).isEqualTo(List.class); + assertThat(requestedType.rawClassParameters()).containsExactly(EnhancedType.of(UnsupportedType.class)); + } + + @Test + @DisplayName("A Collection of String is stored as SS and read as a LinkedHashSet") + void fromBean_whenStringCollection_selectsSetConverterAndReadsLinkedHashSet() { + TableSchema schema = TableSchema.fromBean(StringCollectionBean.class); + StringCollectionBean model = new StringCollectionBean(); + Collection input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + model.setValue(input); + + Map map = schema.itemToMap(model, true); + StringCollectionBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.getValue()).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("A string set is stored as SS and read as a LinkedHashSet") + void fromBean_whenStringSet_selectsSetConverterAndReadsLinkedHashSet() { + TableSchema schema = TableSchema.fromBean(StringSetBean.class); + StringSetBean model = new StringSetBean(); + model.setId("id-1"); + Set input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + model.setValue(input); + + Map map = schema.itemToMap(model, true); + StringSetBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.getValue()).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("A string-to-integer map is stored as M and read as a LinkedHashMap") + void fromBean_whenStringIntegerMap_selectsMapConverterAndReadsLinkedHashMap() { + TableSchema schema = TableSchema.fromBean(StringIntegerMapBean.class); + StringIntegerMapBean model = new StringIntegerMapBean(); + model.setId("id-1"); + Map input = new LinkedHashMap<>(); + input.put("a", 1); + input.put("b", 2); + model.setValue(input); + + Map map = schema.itemToMap(model, true); + StringIntegerMapBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(read.getValue()).isEqualTo(input).isInstanceOf(LinkedHashMap.class); + } + + @Test + @DisplayName("An enum property selects EnumAttributeConverter and round-trips") + void fromBean_whenEnumProperty_selectsEnumConverterAndRoundTrips() { + TableSchema schema = TableSchema.fromBean(EnumBean.class); + EnumBean model = new EnumBean(); + model.setId("id-1"); + model.setValue(TestEnum.OPEN); + + Map map = schema.itemToMap(model, true); + EnumBean read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(EnumAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("OPEN"); + assertThat(read.getValue()).isEqualTo(TestEnum.OPEN); + } + + @Test + @DisplayName("An unconverted HashSet property fails lookup before mapToItem") + void fromBean_whenUnconvertedHashSet_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> TableSchema.fromBean(HashSetBean.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("An unconverted HashMap property fails lookup before mapToItem") + void fromBean_whenUnconvertedHashMap_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> TableSchema.fromBean(HashMapBean.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @DynamoDbBean + public static class StringBean { + private String id; + private String value; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + @DynamoDbBean + public static class StringListBean { + private String id; + private List value; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public List getValue() { + return value; + } + + public void setValue(List value) { + this.value = value; + } + } + + @DynamoDbBean + public static class NestedInnerBean { + private String nestedValue; + + public String getNestedValue() { + return nestedValue; + } + + public void setNestedValue(String nestedValue) { + this.nestedValue = nestedValue; + } + } + + @DynamoDbBean + public static class NestedOuterBean { + private String id; + private NestedInnerBean value; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public NestedInnerBean getValue() { + return value; + } + + public void setValue(NestedInnerBean value) { + this.value = value; + } + } + + @DynamoDbBean + public static class NullReadBean { + private String id; + private String value; + public int valueSetterCalls; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + valueSetterCalls++; + this.value = value; + } + } + + @DynamoDbBean + public static class ObjectBean { + private Object value; + + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } + } + + @DynamoDbBean + public static class UnsupportedBean { + private UnsupportedType value; + + public UnsupportedType getValue() { + return value; + } + + public void setValue(UnsupportedType value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = { + RecordingCustomProvider.class, + DefaultAttributeConverterProvider.class + }) + public static class CustomFirstBean { + private CustomType value; + + public CustomType getValue() { + return value; + } + + public void setValue(CustomType value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = { + ReturningNullProvider.class, + DefaultAttributeConverterProvider.class + }) + public static class NullThenDefaultBean { + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = { + DefaultAttributeConverterProvider.class, + RecordingCustomProvider.class + }) + public static class DefaultThenCustomBean { + private CustomType value; + + public CustomType getValue() { + return value; + } + + public void setValue(CustomType value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = { + ThrowingProvider.class, + DefaultAttributeConverterProvider.class + }) + public static class ThrowingThenDefaultBean { + private CustomType value; + + public CustomType getValue() { + return value; + } + + public void setValue(CustomType value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = { + ObjectProvider.class, + DefaultAttributeConverterProvider.class + }) + public static class ObjectProviderBean { + private Object value; + + @DynamoDbPartitionKey + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = { + ObjectProvider.class, + DefaultAttributeConverterProvider.class + }) + public static class ConvertedObjectBean { + private Object value; + + @DynamoDbConvertedBy(ObjectStringConverter.class) + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } + } + + @DynamoDbBean + public static class ConvertedUnsupportedBean { + private UnsupportedType value; + + @DynamoDbConvertedBy(UnsupportedStringConverter.class) + public UnsupportedType getValue() { + return value; + } + + public void setValue(UnsupportedType value) { + this.value = value; + } + } + + @DynamoDbBean + public static class ConvertedArrayListBean { + private ArrayList value; + + @DynamoDbConvertedBy(ArrayListConverter.class) + public ArrayList getValue() { + return value; + } + + public void setValue(ArrayList value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = {}) + public static class EmptyProvidersBean { + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = {}) + public static class EmptyProvidersConvertedBean { + private String value; + + @DynamoDbConvertedBy(CustomStringConverter.class) + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + @DynamoDbBean + public static class ArrayListBean { + private ArrayList value; + + public ArrayList getValue() { + return value; + } + + public void setValue(ArrayList value) { + this.value = value; + } + } + + @DynamoDbBean(converterProviders = { + UnsupportedTypeOnlyProvider.class, + DefaultAttributeConverterProvider.class + }) + public static class UnsupportedListBean { + private List value; + + public List getValue() { + return value; + } + + public void setValue(List value) { + this.value = value; + } + } + + @DynamoDbBean + public static class StringCollectionBean { + private Collection value; + + public Collection getValue() { + return value; + } + + public void setValue(Collection value) { + this.value = value; + } + } + + @DynamoDbBean + public static class StringSetBean { + private String id; + private Set value; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Set getValue() { + return value; + } + + public void setValue(Set value) { + this.value = value; + } + } + + @DynamoDbBean + public static class StringIntegerMapBean { + private String id; + private Map value; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getValue() { + return value; + } + + public void setValue(Map value) { + this.value = value; + } + } + + @DynamoDbBean + public static class EnumBean { + private String id; + private TestEnum value; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public TestEnum getValue() { + return value; + } + + public void setValue(TestEnum value) { + this.value = value; + } + } + + @DynamoDbBean + public static class HashSetBean { + private HashSet value; + + public HashSet getValue() { + return value; + } + + public void setValue(HashSet value) { + this.value = value; + } + } + + @DynamoDbBean + public static class HashMapBean { + private HashMap value; + + public HashMap getValue() { + return value; + } + + public void setValue(HashMap value) { + this.value = value; + } + } + + public enum TestEnum { + OPEN, + CLOSED + } + + public static final class CustomType { + private final String value; + + public CustomType(String value) { + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CustomType)) { + return false; + } + CustomType that = (CustomType) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } + + public static final class UnsupportedType { + @Override + public boolean equals(Object o) { + return o instanceof UnsupportedType; + } + + @Override + public int hashCode() { + return 1; + } + } + + public static class CustomTypeConverter implements AttributeConverter { + public CustomTypeConverter() { + } + + @Override + public AttributeValue transformFrom(CustomType input) { + return AttributeValue.fromS("custom:" + input.value()); + } + + @Override + public CustomType transformTo(AttributeValue input) { + String stored = input.s(); + return new CustomType(stored.substring("custom:".length())); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(CustomType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static class CustomStringConverter implements AttributeConverter { + public CustomStringConverter() { + } + + @Override + public AttributeValue transformFrom(String input) { + return AttributeValue.fromS("custom:" + input); + } + + @Override + public String transformTo(AttributeValue input) { + String stored = input.s(); + if (stored != null && stored.startsWith("custom:")) { + return stored.substring("custom:".length()); + } + return stored; + } + + @Override + public EnhancedType type() { + return EnhancedType.of(String.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static class ObjectStringConverter implements AttributeConverter { + public ObjectStringConverter() { + } + + @Override + public AttributeValue transformFrom(Object input) { + return AttributeValue.fromS("custom"); + } + + @Override + public Object transformTo(AttributeValue input) { + return "custom"; + } + + @Override + public EnhancedType type() { + return EnhancedType.of(Object.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static class UnsupportedStringConverter implements AttributeConverter { + public UnsupportedStringConverter() { + } + + @Override + public AttributeValue transformFrom(UnsupportedType input) { + return AttributeValue.fromS("custom"); + } + + @Override + public UnsupportedType transformTo(AttributeValue input) { + return new UnsupportedType(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(UnsupportedType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static class ArrayListConverter implements AttributeConverter> { + public ArrayListConverter() { + } + + @Override + public AttributeValue transformFrom(ArrayList input) { + List values = new ArrayList<>(); + for (String member : input) { + values.add(AttributeValue.fromS(member)); + } + return AttributeValue.fromL(values); + } + + @Override + public ArrayList transformTo(AttributeValue input) { + ArrayList result = new ArrayList<>(); + for (AttributeValue member : input.l()) { + result.add(member.s()); + } + return result; + } + + @Override + public EnhancedType> type() { + return new EnhancedType>() { + }; + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.L; + } + } + + public static class RecordingCustomProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public RecordingCustomProvider() { + CURRENT.set(this); + } + + public static RecordingCustomProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(CustomType.class).equals(enhancedType)) { + return (AttributeConverter) new CustomTypeConverter(); + } + return null; + } + } + + public static class ReturningNullProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public ReturningNullProvider() { + CURRENT.set(this); + } + + public static ReturningNullProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return null; + } + } + + public static class ThrowingProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + public ThrowingProvider() { + CURRENT.set(this); + } + + public static ThrowingProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + throw new IllegalArgumentException("Attribute converter provider failed while looking up " + enhancedType); + } + } + + public static class ObjectProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public ObjectProvider() { + CURRENT.set(this); + } + + public static ObjectProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(Object.class).equals(enhancedType)) { + return (AttributeConverter) new ObjectStringConverter(); + } + return null; + } + } + + public static class UnsupportedTypeOnlyProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public UnsupportedTypeOnlyProvider() { + CURRENT.set(this); + } + + public static UnsupportedTypeOnlyProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(UnsupportedType.class).equals(enhancedType)) { + return (AttributeConverter) new UnsupportedStringConverter(); + } + return null; + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderBuiltInTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderBuiltInTest.java new file mode 100644 index 000000000000..cbcc2b368117 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderBuiltInTest.java @@ -0,0 +1,501 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.Period; +import java.time.Year; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalDouble; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiConsumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.core.SdkNumber; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.AtomicBooleanAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.AtomicIntegerAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.AtomicLongAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.BigDecimalAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.BigIntegerAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.BooleanAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ByteArrayAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ByteAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ByteBufferAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.CharSequenceAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.CharacterArrayAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.CharacterAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DocumentAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DoubleAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DurationAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.FloatAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.InstantAsStringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.IntegerAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalDateAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalDateTimeAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocalTimeAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LocaleAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.LongAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MonthDayAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.OffsetDateTimeAsStringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.OptionalDoubleAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.OptionalIntAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.OptionalLongAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.PeriodAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SdkBytesAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SdkNumberAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ShortAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringBufferAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringBuilderAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.UriAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.UrlAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.UuidAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ZoneIdAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ZoneOffsetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ZonedDateTimeAsStringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.protocols.jsoncore.JsonNode; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests the scalar converters registered by the default attribute converter provider. + *

+ * The tests verify conversion to and from DynamoDB attribute values for each registered Java type. They also check + * primitive aliases, converter identity, declared converter types, and types that are intentionally absent from the + * default converter cache. + */ +public class DefaultAttributeConverterProviderBuiltInTest { + + private static final BiConsumer EQUAL_TO_INPUT = + (input, read) -> assertThat(read).isEqualTo(input); + + private final DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + @ParameterizedTest(name = "{0} registration converts using {1}") + @MethodSource("builtInTypes") + @DisplayName("Registered built-in types convert to AttributeValue and back") + void converterFor_whenRegisteredBuiltInType_convertsToAttributeValueAndBack(String typeName, + String converterName, + BuiltInCase testCase) { + AttributeConverter converter = converterFor(testCase.type); + + AttributeValue stored = converter.transformFrom(testCase.input); + Object read = converter.transformTo(stored); + + assertThat(converter).as(typeName).isInstanceOf(testCase.converterClass); + assertThat(converter.getClass().getSimpleName()).isEqualTo(converterName); + assertThat(converter.type()).isEqualTo(testCase.type); + assertThat(converter.attributeValueType()).isEqualTo(testCase.attributeValueType); + assertThat(stored).isEqualTo(testCase.expectedStored); + if (testCase.input instanceof ByteBuffer) { + assertThat(stored.b().asByteArray()).containsExactly((byte) 1, (byte) 2); + } + testCase.reconstructed.accept(testCase.input, read); + } + + @ParameterizedTest(name = "{0} registration converts using {1}") + @MethodSource("primitiveAliases") + @DisplayName("Primitive aliases share the wrapper converter and convert using the wrapper declared type") + void converterFor_whenPrimitiveType_sharesWrapperConverterAndConvertsRoundTrip(String typeName, + String converterName, + PrimitiveCase testCase) { + AttributeConverter primitive = converterFor(EnhancedType.of(testCase.primitiveClass)); + AttributeConverter wrapper = converterFor(EnhancedType.of(testCase.wrapperClass)); + + AttributeValue stored = primitive.transformFrom(testCase.input); + Object read = primitive.transformTo(stored); + + assertThat(primitive).as(typeName).isInstanceOf(testCase.converterClass); + assertThat(primitive.getClass().getSimpleName()).isEqualTo(converterName); + assertThat(primitive).isSameAs(wrapper); + assertThat(primitive.type()).isEqualTo(EnhancedType.of(testCase.wrapperClass)); + assertThat(primitive.attributeValueType()).isEqualTo(testCase.attributeValueType); + assertThat(stored).isEqualTo(testCase.expectedStored); + assertThat(read).isEqualTo(testCase.input); + } + + @ParameterizedTest(name = "{0} has no default cache key") + @MethodSource("unregisteredTypes") + @DisplayName("Types with a converter class or string converter still fail when they are not default cache keys") + void converterFor_whenUnregisteredType_throwsIllegalStateException(String typeName, EnhancedType type) { + assertThatThrownBy(() -> provider.converterFor(type)) + .as(typeName) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + static Stream builtInTypes() { + Instant instant = Instant.parse("2020-01-02T03:04:05Z"); + OffsetDateTime offsetDateTime = OffsetDateTime.parse("2020-01-02T03:04:05+02:00"); + ZonedDateTime zonedDateTime = ZonedDateTime.parse("2020-01-02T03:04:05+01:00[Europe/Paris]"); + byte[] bytes = new byte[] {1, 2}; + ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[] {1, 2}); + SdkBytes sdkBytes = SdkBytes.fromUtf8String("ab"); + URL url = url("https://example.com/a"); + List stringList = new ArrayList<>(); + stringList.add("a"); + stringList.add("b"); + Set stringSet = new LinkedHashSet<>(); + stringSet.add("a"); + stringSet.add("b"); + Map stringIntegerMap = new LinkedHashMap<>(); + stringIntegerMap.put("one", 1); + TableSchema documentSchema = + StaticTableSchema.builder(DocumentType.class) + .newItemSupplier(DocumentType::new) + .addAttribute(String.class, a -> a.name("name") + .getter(DocumentType::getName) + .setter(DocumentType::setName)) + .build(); + EnhancedType documentType = EnhancedType.documentOf(DocumentType.class, documentSchema); + DocumentType document = new DocumentType(); + document.setName("child"); + + return Stream.of( + builtIn("AtomicBoolean", AtomicBooleanAttributeConverter.class, EnhancedType.of(AtomicBoolean.class), + new AtomicBoolean(true), bool(true), AttributeValueType.BOOL, + (input, read) -> assertThat(((AtomicBoolean) read).get()).isTrue()), + builtIn("AtomicInteger", AtomicIntegerAttributeConverter.class, EnhancedType.of(AtomicInteger.class), + new AtomicInteger(7), n("7"), AttributeValueType.S, + (input, read) -> assertThat(((AtomicInteger) read).get()).isEqualTo(7)), + builtIn("AtomicLong", AtomicLongAttributeConverter.class, EnhancedType.of(AtomicLong.class), + new AtomicLong(7L), n("7"), AttributeValueType.N, + (input, read) -> assertThat(((AtomicLong) read).get()).isEqualTo(7L)), + builtIn("BigDecimal", BigDecimalAttributeConverter.class, EnhancedType.of(BigDecimal.class), + new BigDecimal("1.25"), n("1.25"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("BigInteger", BigIntegerAttributeConverter.class, EnhancedType.of(BigInteger.class), + new BigInteger("12345678901234567890"), n("12345678901234567890"), AttributeValueType.N, + EQUAL_TO_INPUT), + builtIn("Boolean", BooleanAttributeConverter.class, EnhancedType.of(Boolean.class), + Boolean.TRUE, bool(true), AttributeValueType.BOOL, EQUAL_TO_INPUT), + builtIn("byte[]", ByteArrayAttributeConverter.class, EnhancedType.of(byte[].class), + bytes, binary(SdkBytes.fromByteArray(bytes)), AttributeValueType.B, + (input, read) -> assertThat((byte[]) read).containsExactly((byte) 1, (byte) 2)), + builtIn("ByteBuffer", ByteBufferAttributeConverter.class, EnhancedType.of(ByteBuffer.class), + byteBuffer, binary(SdkBytes.fromByteArray(new byte[] {1, 2})), AttributeValueType.B, + DefaultAttributeConverterProviderBuiltInTest::assertByteBufferRoundTrip), + builtIn("Byte", ByteAttributeConverter.class, EnhancedType.of(Byte.class), + Byte.valueOf((byte) 7), n("7"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("char[]", CharacterArrayAttributeConverter.class, EnhancedType.of(char[].class), + new char[] {'a', 'b'}, s("ab"), AttributeValueType.S, + (input, read) -> assertThat((char[]) read).containsExactly('a', 'b')), + builtIn("Character", CharacterAttributeConverter.class, EnhancedType.of(Character.class), + Character.valueOf('x'), s("x"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("CharSequence", CharSequenceAttributeConverter.class, EnhancedType.of(CharSequence.class), + "text", s("text"), AttributeValueType.S, + (input, read) -> assertThat(read).isInstanceOf(String.class).isEqualTo("text")), + builtIn("Double", DoubleAttributeConverter.class, EnhancedType.of(Double.class), + Double.valueOf(1.5d), n("1.5"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("Duration", DurationAttributeConverter.class, EnhancedType.of(Duration.class), + Duration.ofSeconds(90), n("90"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("Float", FloatAttributeConverter.class, EnhancedType.of(Float.class), + Float.valueOf(1.5f), n("1.5"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("Instant", InstantAsStringAttributeConverter.class, EnhancedType.of(Instant.class), + instant, s("2020-01-02T03:04:05Z"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("Integer", IntegerAttributeConverter.class, EnhancedType.of(Integer.class), + Integer.valueOf(7), n("7"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("LocalDate", LocalDateAttributeConverter.class, EnhancedType.of(LocalDate.class), + LocalDate.parse("2020-01-02"), s("2020-01-02"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("LocalDateTime", LocalDateTimeAttributeConverter.class, EnhancedType.of(LocalDateTime.class), + LocalDateTime.parse("2020-01-02T03:04:05"), s("2020-01-02T03:04:05"), AttributeValueType.S, + EQUAL_TO_INPUT), + builtIn("Locale", LocaleAttributeConverter.class, EnhancedType.of(Locale.class), + Locale.forLanguageTag("en-US"), s("en-US"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("LocalTime", LocalTimeAttributeConverter.class, EnhancedType.of(LocalTime.class), + LocalTime.parse("03:04:05"), s("03:04:05"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("Long", LongAttributeConverter.class, EnhancedType.of(Long.class), + Long.valueOf(7L), n("7"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("MonthDay", MonthDayAttributeConverter.class, EnhancedType.of(MonthDay.class), + MonthDay.of(1, 2), s("--01-02"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("OffsetDateTime", OffsetDateTimeAsStringAttributeConverter.class, + EnhancedType.of(OffsetDateTime.class), offsetDateTime, s(offsetDateTime.toString()), + AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("OptionalDouble", OptionalDoubleAttributeConverter.class, EnhancedType.of(OptionalDouble.class), + OptionalDouble.of(1.5d), n("1.5"), AttributeValueType.N, + (input, read) -> assertThat(((OptionalDouble) read).getAsDouble()).isEqualTo(1.5d)), + builtIn("OptionalInt", OptionalIntAttributeConverter.class, EnhancedType.of(OptionalInt.class), + OptionalInt.of(7), n("7"), AttributeValueType.N, + (input, read) -> assertThat(((OptionalInt) read).getAsInt()).isEqualTo(7)), + builtIn("OptionalLong", OptionalLongAttributeConverter.class, EnhancedType.of(OptionalLong.class), + OptionalLong.of(7L), n("7"), AttributeValueType.N, + (input, read) -> assertThat(((OptionalLong) read).getAsLong()).isEqualTo(7L)), + builtIn("Period", PeriodAttributeConverter.class, EnhancedType.of(Period.class), + Period.of(1, 2, 3), s("P1Y2M3D"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("SdkBytes", SdkBytesAttributeConverter.class, EnhancedType.of(SdkBytes.class), + sdkBytes, binary(sdkBytes), AttributeValueType.B, EQUAL_TO_INPUT), + builtIn("Short", ShortAttributeConverter.class, EnhancedType.of(Short.class), + Short.valueOf((short) 7), n("7"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("String", StringAttributeConverter.class, EnhancedType.of(String.class), + "text", s("text"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("StringBuffer", StringBufferAttributeConverter.class, EnhancedType.of(StringBuffer.class), + new StringBuffer("text"), s("text"), AttributeValueType.S, + (input, read) -> assertThat(read.toString()).isEqualTo("text")), + builtIn("StringBuilder", StringBuilderAttributeConverter.class, EnhancedType.of(StringBuilder.class), + new StringBuilder("text"), s("text"), AttributeValueType.S, + (input, read) -> assertThat(read.toString()).isEqualTo("text")), + builtIn("URI", UriAttributeConverter.class, EnhancedType.of(URI.class), + URI.create("https://example.com/a"), s("https://example.com/a"), AttributeValueType.S, + EQUAL_TO_INPUT), + builtIn("URL", UrlAttributeConverter.class, EnhancedType.of(URL.class), + url, s("https://example.com/a"), AttributeValueType.S, + (input, read) -> assertThat(((URL) read).toExternalForm()) + .isEqualTo(((URL) input).toExternalForm())), + builtIn("UUID", UuidAttributeConverter.class, EnhancedType.of(UUID.class), + UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + s("123e4567-e89b-12d3-a456-426614174000"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("ZonedDateTime", ZonedDateTimeAsStringAttributeConverter.class, EnhancedType.of(ZonedDateTime.class), + zonedDateTime, s(zonedDateTime.toString()), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("ZoneId", ZoneIdAttributeConverter.class, EnhancedType.of(ZoneId.class), + ZoneId.of("Europe/Paris"), s("Europe/Paris"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("ZoneOffset", ZoneOffsetAttributeConverter.class, EnhancedType.of(ZoneOffset.class), + ZoneOffset.ofHours(2), s("+02:00"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("SdkNumber", SdkNumberAttributeConverter.class, EnhancedType.of(SdkNumber.class), + SdkNumber.fromString("1.25"), n("1.25"), AttributeValueType.N, EQUAL_TO_INPUT), + builtIn("List", ListAttributeConverter.class, EnhancedType.listOf(String.class), + stringList, l(s("a"), s("b")), AttributeValueType.L, + (input, read) -> assertThat(read).isInstanceOf(ArrayList.class).isEqualTo(stringList)), + builtIn("Set", SetAttributeConverter.class, EnhancedType.setOf(String.class), + stringSet, ss("a", "b"), AttributeValueType.SS, + (input, read) -> assertThat(read).isInstanceOf(LinkedHashSet.class).isEqualTo(stringSet)), + builtIn("Map", MapAttributeConverter.class, EnhancedType.mapOf(String.class, Integer.class), + stringIntegerMap, m(Collections.singletonMap("one", n("1"))), AttributeValueType.M, + (input, read) -> assertThat(read).isInstanceOf(LinkedHashMap.class).isEqualTo(stringIntegerMap)), + builtIn("Enum", EnumAttributeConverter.class, EnhancedType.of(TestEnum.class), + TestEnum.OPEN, s("OPEN"), AttributeValueType.S, EQUAL_TO_INPUT), + builtIn("Document", DocumentAttributeConverter.class, documentType, + document, m(Collections.singletonMap("name", s("child"))), AttributeValueType.M, EQUAL_TO_INPUT) + ); + } + + static Stream unregisteredTypes() { + return Stream.of( + Arguments.of("Optional", EnhancedType.optionalOf(String.class)), + Arguments.of("JsonNode", EnhancedType.of(JsonNode.class)), + Arguments.of("Year", EnhancedType.of(Year.class)) + ); + } + + static Stream primitiveAliases() { + return Stream.of( + primitive("boolean", boolean.class, Boolean.class, BooleanAttributeConverter.class, + true, bool(true), AttributeValueType.BOOL), + primitive("byte", byte.class, Byte.class, ByteAttributeConverter.class, + (byte) 7, n("7"), AttributeValueType.N), + primitive("short", short.class, Short.class, ShortAttributeConverter.class, + (short) 7, n("7"), AttributeValueType.N), + primitive("int", int.class, Integer.class, IntegerAttributeConverter.class, + 7, n("7"), AttributeValueType.N), + primitive("long", long.class, Long.class, LongAttributeConverter.class, + 7L, n("7"), AttributeValueType.N), + primitive("float", float.class, Float.class, FloatAttributeConverter.class, + 1.5f, n("1.5"), AttributeValueType.N), + primitive("double", double.class, Double.class, DoubleAttributeConverter.class, + 1.5d, n("1.5"), AttributeValueType.N), + primitive("char", char.class, Character.class, CharacterAttributeConverter.class, + 'x', s("x"), AttributeValueType.S) + ); + } + + @SuppressWarnings("unchecked") + private AttributeConverter converterFor(EnhancedType type) { + return (AttributeConverter) provider.converterFor(type); + } + + private static void assertByteBufferRoundTrip(Object input, Object read) { + ByteBuffer inputBuffer = ((ByteBuffer) input).duplicate(); + ByteBuffer readBuffer = ((ByteBuffer) read).duplicate(); + assertThat(readBuffer).isEqualTo(inputBuffer); + assertThat(((ByteBuffer) read).equals(input)).isTrue(); + } + + private static Arguments builtIn(String typeName, + Class converterClass, + EnhancedType type, + Object input, + AttributeValue expectedStored, + AttributeValueType attributeValueType, + BiConsumer reconstructed) { + return Arguments.of(typeName, converterClass.getSimpleName(), + new BuiltInCase(type, converterClass, input, expectedStored, attributeValueType, + reconstructed)); + } + + private static Arguments primitive(String typeName, + Class primitiveClass, + Class wrapperClass, + Class converterClass, + Object input, + AttributeValue expectedStored, + AttributeValueType attributeValueType) { + return Arguments.of(typeName, converterClass.getSimpleName(), + new PrimitiveCase(primitiveClass, wrapperClass, converterClass, input, expectedStored, + attributeValueType)); + } + + private static AttributeValue bool(boolean value) { + return AttributeValue.builder().bool(value).build(); + } + + private static AttributeValue n(String value) { + return AttributeValue.builder().n(value).build(); + } + + private static AttributeValue s(String value) { + return AttributeValue.builder().s(value).build(); + } + + private static AttributeValue binary(SdkBytes value) { + return AttributeValue.builder().b(value).build(); + } + + private static AttributeValue l(AttributeValue... values) { + return AttributeValue.builder().l(Arrays.asList(values)).build(); + } + + private static AttributeValue ss(String... values) { + return AttributeValue.builder().ss(Arrays.asList(values)).build(); + } + + private static AttributeValue m(Map members) { + return AttributeValue.builder().m(members).build(); + } + + private static URL url(String value) { + try { + return new URL(value); + } catch (MalformedURLException e) { + throw new IllegalStateException(e); + } + } + + private static final class BuiltInCase { + private final EnhancedType type; + private final Class converterClass; + private final Object input; + private final AttributeValue expectedStored; + private final AttributeValueType attributeValueType; + private final BiConsumer reconstructed; + + private BuiltInCase(EnhancedType type, + Class converterClass, + Object input, + AttributeValue expectedStored, + AttributeValueType attributeValueType, + BiConsumer reconstructed) { + this.type = type; + this.converterClass = converterClass; + this.input = input; + this.expectedStored = expectedStored; + this.attributeValueType = attributeValueType; + this.reconstructed = reconstructed; + } + } + + private static final class PrimitiveCase { + private final Class primitiveClass; + private final Class wrapperClass; + private final Class converterClass; + private final Object input; + private final AttributeValue expectedStored; + private final AttributeValueType attributeValueType; + + private PrimitiveCase(Class primitiveClass, + Class wrapperClass, + Class converterClass, + Object input, + AttributeValue expectedStored, + AttributeValueType attributeValueType) { + this.primitiveClass = primitiveClass; + this.wrapperClass = wrapperClass; + this.converterClass = converterClass; + this.input = input; + this.expectedStored = expectedStored; + this.attributeValueType = attributeValueType; + } + } + + enum TestEnum { + OPEN, + CLOSED + } + + static final class DocumentType { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DocumentType)) { + return false; + } + return Objects.equals(name, ((DocumentType) o).name); + } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderCacheTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderCacheTest.java new file mode 100644 index 000000000000..d69f54573e37 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderCacheTest.java @@ -0,0 +1,412 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import org.apache.logging.log4j.core.LogEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.event.Level; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DocumentAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; + +/** + * Tests caching behavior for registered and generated attribute converters. + *

+ * The tests compare converter identity across equal and different type tokens, providers, and document types. They + * also exercise concurrent cache misses, failed lookups, conversion failures after caching, and debug records emitted + * for successful and unsuccessful converter searches. + */ +public class DefaultAttributeConverterProviderCacheTest { + + @Test + @DisplayName("Equal map tokens return the same cached map converter") + void converterFor_whenEqualMapTokens_returnsSameCachedMapAttributeConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> firstType = EnhancedType.mapOf(String.class, Integer.class); + EnhancedType> secondType = EnhancedType.mapOf(String.class, Integer.class); + + AttributeConverter> first = provider.converterFor(firstType); + AttributeConverter> second = provider.converterFor(secondType); + + assertThat(first).isInstanceOf(MapAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isSameAs(first); + } + + @Test + @DisplayName("Equal set tokens return the same cached set converter") + void converterFor_whenEqualSetTokens_returnsSameCachedSetAttributeConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> firstType = EnhancedType.setOf(String.class); + EnhancedType> secondType = EnhancedType.setOf(String.class); + + AttributeConverter> first = provider.converterFor(firstType); + AttributeConverter> second = provider.converterFor(secondType); + + assertThat(first).isInstanceOf(SetAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(second).isSameAs(first); + } + + @Test + @DisplayName("Equal list tokens return different list converters") + void converterFor_whenEqualListTokens_returnsDifferentListAttributeConverters() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> firstType = EnhancedType.listOf(String.class); + EnhancedType> secondType = EnhancedType.listOf(String.class); + + AttributeConverter> first = provider.converterFor(firstType); + AttributeConverter> second = provider.converterFor(secondType); + + assertThat(first).isInstanceOf(ListAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(second).isInstanceOf(ListAttributeConverter.class); + assertThat(second.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(second).isNotSameAs(first); + } + + @Test + @DisplayName("An enum type looked up twice returns different enum converters") + void converterFor_whenEnumTypeCalledTwice_returnsDifferentEnumAttributeConverters() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(TestEnum.class); + + AttributeConverter first = provider.converterFor(type); + AttributeConverter second = provider.converterFor(type); + + assertThat(first).isInstanceOf(EnumAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(second).isInstanceOf(EnumAttributeConverter.class); + assertThat(second.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(second).isNotSameAs(first); + } + + @Test + @DisplayName("Equal named document tokens return the same cached document converter") + void converterFor_whenEqualNamedDocumentTokens_returnsSameCachedDocumentAttributeConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema schema = unusedSchema(DocumentType.class, DocumentType::new); + EnhancedType firstType = EnhancedType.documentOf(DocumentType.class, schema); + EnhancedType secondType = EnhancedType.documentOf(DocumentType.class, schema); + + AttributeConverter first = provider.converterFor(firstType); + AttributeConverter second = provider.converterFor(secondType); + + assertThat(first).isInstanceOf(DocumentAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isSameAs(first); + } + + @Test + @DisplayName("An anonymous Map raw class document is not cached") + void converterFor_whenAnonymousMapRawClassDocument_returnsUncachedDocumentAttributeConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + LinkedHashMap anonymousMap = new LinkedHashMap() { + }; + TableSchema> recordingSchema = + unusedLinkedHashMapSchema(anonymousMap); + @SuppressWarnings("unchecked") + EnhancedType> type = + EnhancedType.documentOf((Class) anonymousMap.getClass(), recordingSchema); + + AttributeConverter> first = provider.converterFor(type); + AttributeConverter> second = provider.converterFor(type); + + assertThat(first).isInstanceOf(DocumentAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isInstanceOf(DocumentAttributeConverter.class); + assertThat(second.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isNotSameAs(first); + } + + @Test + @DisplayName("An anonymous Set raw class document is not cached") + void converterFor_whenAnonymousSetRawClassDocument_returnsUncachedDocumentAttributeConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + LinkedHashSet anonymousSet = new LinkedHashSet() { + }; + TableSchema> recordingSchema = unusedLinkedHashSetSchema(anonymousSet); + @SuppressWarnings("unchecked") + EnhancedType> type = + EnhancedType.documentOf((Class) anonymousSet.getClass(), recordingSchema); + + AttributeConverter> first = provider.converterFor(type); + AttributeConverter> second = provider.converterFor(type); + + assertThat(first).isInstanceOf(DocumentAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isInstanceOf(DocumentAttributeConverter.class); + assertThat(second.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isNotSameAs(first); + } + + @Test + @DisplayName("A named custom Map document returns the same cached document converter") + void converterFor_whenNamedCustomMapDocument_returnsSameCachedDocumentAttributeConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema schema = unusedSchema(CustomMap.class, CustomMap::new); + EnhancedType type = EnhancedType.documentOf(CustomMap.class, schema); + + AttributeConverter first = provider.converterFor(type); + AttributeConverter second = provider.converterFor(type); + + assertThat(first).isInstanceOf(DocumentAttributeConverter.class); + assertThat(first).isNotInstanceOf(MapAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isSameAs(first); + } + + @Test + @DisplayName("Separately constructed equal map tokens share one cache key") + void converterFor_whenSeparatelyConstructedEqualMapTokens_useOneCacheKey() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> firstType = EnhancedType.mapOf(String.class, Integer.class); + EnhancedType> secondType = EnhancedType.mapOf(String.class, Integer.class); + + AttributeConverter> first = provider.converterFor(firstType); + AttributeConverter> second = provider.converterFor(secondType); + + assertThat(firstType).isNotSameAs(secondType); + assertThat(firstType).isEqualTo(secondType); + assertThat(first).isInstanceOf(MapAttributeConverter.class); + assertThat(second).isSameAs(first); + } + + @Test + @DisplayName("A UUID key map is not the string key map converter") + void converterFor_whenUuidKeyMap_isNotStringKeyMapConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> stringKeyType = EnhancedType.mapOf(String.class, Integer.class); + EnhancedType> uuidKeyType = EnhancedType.mapOf(UUID.class, Integer.class); + + AttributeConverter> stringKeyConverter = provider.converterFor(stringKeyType); + AttributeConverter> uuidKeyConverter = provider.converterFor(uuidKeyType); + + assertThat(uuidKeyConverter).isInstanceOf(MapAttributeConverter.class); + assertThat(uuidKeyConverter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(uuidKeyConverter).isNotSameAs(stringKeyConverter); + } + + @Test + @DisplayName("A Long value map is not the Integer value map converter") + void converterFor_whenLongValueMap_isNotIntegerValueMapConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> integerValueType = EnhancedType.mapOf(String.class, Integer.class); + EnhancedType> longValueType = EnhancedType.mapOf(String.class, Long.class); + + AttributeConverter> integerValueConverter = provider.converterFor(integerValueType); + AttributeConverter> longValueConverter = provider.converterFor(longValueType); + + assertThat(longValueConverter).isInstanceOf(MapAttributeConverter.class); + assertThat(longValueConverter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(longValueConverter).isNotSameAs(integerValueConverter); + } + + @Test + @DisplayName("A failed unsupported map value lookup does not cache the failure") + void converterFor_whenFailedUnsupportedMapValue_doesNotCacheFailure() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, UnsupportedType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Concurrent map misses leave a later lookup matching one concurrent result") + void converterFor_whenConcurrentMapMisses_laterLookupMatchesOneConcurrentResult() throws Exception { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + int threadCount = 8; + CyclicBarrier startBarrier = new CyclicBarrier(threadCount + 1); + List>> concurrentResults = + Collections.synchronizedList(new ArrayList<>()); + List failures = Collections.synchronizedList(new ArrayList<>()); + Thread[] threads = new Thread[threadCount]; + + for (int i = 0; i < threadCount; i++) { + threads[i] = new Thread(() -> { + try { + startBarrier.await(5, TimeUnit.SECONDS); + concurrentResults.add(provider.converterFor(type)); + } catch (Throwable t) { + failures.add(t); + } + }); + threads[i].start(); + } + + startBarrier.await(5, TimeUnit.SECONDS); + + for (int i = 0; i < threadCount; i++) { + threads[i].join(TimeUnit.SECONDS.toMillis(5)); + assertThat(threads[i].isAlive()).isFalse(); + } + + assertThat(failures).isEmpty(); + assertThat(concurrentResults).hasSize(threadCount); + for (AttributeConverter> converter : concurrentResults) { + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + } + + AttributeConverter> later = provider.converterFor(type); + + assertThat(later).isInstanceOf(MapAttributeConverter.class); + assertThat(later.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(concurrentResults).anySatisfy(result -> assertThat(result).isSameAs(later)); + } + + @Test + @DisplayName("A successful String lookup emits the exact debug record") + void converterFor_whenSuccessfulStringLookup_emitsExactDebugRecord() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + + try (LogCaptor logCaptor = new LogCaptor(DefaultAttributeConverterProvider.class, Level.DEBUG)) { + provider.converterFor(EnhancedType.of(String.class)); + + List logEvents = logCaptor.loggedEvents(); + assertThat(logEvents).hasSize(1); + assertThat(logEvents.get(0).getLevel().name()).isEqualTo(Level.DEBUG.name()); + assertThat(logEvents.get(0).getMessage().getFormattedMessage()) + .isEqualTo("Converter for EnhancedType(java.lang.String): software.amazon.awssdk.enhanced.dynamodb.internal" + + ".converter.attribute.StringAttributeConverter"); + } + } + + @Test + @DisplayName("A missing converter emits the exact debug record and throws") + void converterFor_whenMissingConverter_emitsExactDebugRecordAndThrows() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + try (LogCaptor logCaptor = new LogCaptor(DefaultAttributeConverterProvider.class, Level.DEBUG)) { + Throwable thrown = catchThrowable(() -> provider.converterFor(type)); + + assertThat(thrown).isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + List logEvents = logCaptor.loggedEvents(); + assertThat(logEvents).hasSize(1); + assertThat(logEvents.get(0).getLevel().name()).isEqualTo(Level.DEBUG.name()); + assertThat(logEvents.get(0).getMessage().getFormattedMessage()) + .isEqualTo("No converter available for " + type); + } + } + + @Test + @DisplayName("A failed Boolean set lookup does not cache the failure") + void converterFor_whenFailedBooleanSet_doesNotCacheFailure() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(Boolean.class); + String expectedMessage = + "SetAttributeConverter cannot be created with a parameterized type of 'class java.lang.Boolean'. " + + "Supported parameterized types must convert to B, S or N DynamoDB AttributeValues."; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage); + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage); + } + + @Test + @DisplayName("A document conversion failure does not evict the cached converter") + void converterFor_whenDocumentConversionFailure_doesNotEvictCachedConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema schema = throwingWriteSchema(); + EnhancedType type = EnhancedType.documentOf(ThrowingWriteDocument.class, schema); + ThrowingWriteDocument input = new ThrowingWriteDocument(); + + AttributeConverter first = provider.converterFor(type); + assertThatThrownBy(() -> first.transformFrom(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Document schema failed while converting ThrowingWriteDocument to a map"); + AttributeConverter second = provider.converterFor(type); + + assertThat(first).isInstanceOf(DocumentAttributeConverter.class); + assertThat(second).isSameAs(first); + } + + private static TableSchema unusedSchema(Class type, java.util.function.Supplier supplier) { + return StaticTableSchema.builder(type).newItemSupplier(supplier).build(); + } + + @SuppressWarnings("unchecked") + private static TableSchema> unusedLinkedHashMapSchema( + LinkedHashMap instance) { + return unusedSchema((Class>) instance.getClass(), () -> instance); + } + + @SuppressWarnings("unchecked") + private static TableSchema> unusedLinkedHashSetSchema(LinkedHashSet instance) { + return unusedSchema((Class>) instance.getClass(), () -> instance); + } + + private static TableSchema throwingWriteSchema() { + return StaticTableSchema.builder(ThrowingWriteDocument.class) + .newItemSupplier(ThrowingWriteDocument::new) + .addAttribute(String.class, a -> a.name("name") + .getter(ThrowingWriteDocument::getName) + .setter(ThrowingWriteDocument::setName)) + .build(); + } + + static class UnsupportedType { + } + + static class DocumentType { + } + + static class ThrowingWriteDocument { + public String getName() { + throw new IllegalArgumentException("Document schema failed while converting ThrowingWriteDocument to a map"); + } + + public void setName(String name) { + } + } + + enum TestEnum { + OPEN, + CLOSED + } + + static class CustomMap extends HashMap { + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderConversionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderConversionTest.java new file mode 100644 index 000000000000..d466e26c2bc4 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderConversionTest.java @@ -0,0 +1,955 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.OptionalInt; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests converter behavior after lookup and the type tokens used to request converters. + *

+ * The tests cover unsupported type shapes, incorrect DynamoDB attribute forms, Java null values, DynamoDB null values, + * and enumeration conversion. They also verify propagation of failures from collection members and table schemas, + * including document conversion options passed to a schema. + */ +public class DefaultAttributeConverterProviderConversionTest { + + @Test + @DisplayName("Null class fails during token construction before provider lookup") + void of_whenNullClass_throwsIllegalArgumentExceptionBeforeProviderLookup() { + assertThatThrownBy(() -> EnhancedType.of((Class) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("class java.lang.Object isn't parameterized"); + } + + @Test + @DisplayName("Unbounded wildcard construction succeeds and later rawClass fails") + void of_whenUnboundedWildcardType_isWildcardAndRawClassThrowsIllegalArgumentException() { + EnhancedType type = EnhancedType.of(wildcardTypeArgument("unbounded")); + + assertThat(type.isWildcard()).isTrue(); + assertThat(type.toString()).isEqualTo("EnhancedType(?)"); + assertThatThrownBy(type::rawClass) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A wildcard type is not expected here."); + } + + @Test + @DisplayName("Upper-bounded wildcard construction succeeds and later rawClass fails") + void of_whenUpperBoundedWildcardType_isWildcardAndRawClassThrowsIllegalArgumentException() { + EnhancedType type = EnhancedType.of(wildcardTypeArgument("upperBounded")); + + assertThat(type.isWildcard()).isTrue(); + assertThatThrownBy(type::rawClass) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A wildcard type is not expected here."); + } + + @Test + @DisplayName("Lower-bounded wildcard construction succeeds and later rawClass fails") + void of_whenLowerBoundedWildcardType_isWildcardAndRawClassThrowsIllegalArgumentException() { + EnhancedType type = EnhancedType.of(wildcardTypeArgument("lowerBounded")); + + assertThat(type.isWildcard()).isTrue(); + assertThatThrownBy(type::rawClass) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A wildcard type is not expected here."); + } + + @Test + @DisplayName("Type variable fails during construction") + void of_whenTypeVariable_throwsIllegalStateException() { + assertThatThrownBy(() -> EnhancedType.of(genericHolderFieldType("typeVariable"))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Type variable type T is not supported."); + } + + @Test + @DisplayName("Generic array fails during construction") + void of_whenGenericArrayType_throwsIllegalStateException() { + assertThatThrownBy(() -> EnhancedType.of(genericHolderFieldType("genericArray"))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Array type T[] is not supported. Use java.util.List instead of arrays."); + } + + @Test + @DisplayName("Parameterized Optional has no default converter") + void converterFor_whenOptionalOfString_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.optionalOf(String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Primitive int array has no default converter") + void converterFor_whenIntArray_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(int[].class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(int[])"); + } + + @Test + @DisplayName("String array has no default converter") + void converterFor_whenStringArray_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(String[].class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.String[])"); + } + + @Test + @DisplayName("Abstract Number has no exact registration") + void converterFor_whenNumberClass_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(Number.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.Number)"); + } + + @Test + @DisplayName("Unsupported interface reaches normal lookup failure") + void converterFor_whenUnsupportedInterface_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(UnsupportedInterface.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Unsupported parameterized application type reaches normal failure") + void converterFor_whenCapturedEnvelopeString_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A mappable class alone is not a document token") + void converterFor_whenDocumentTypeWithoutAttachedSchema_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(DocumentType.class); + + assertThat(type.tableSchema()).isEmpty(); + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Unsupported abstract class reaches normal lookup failure") + void converterFor_whenAbstractUnsupportedType_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(AbstractUnsupportedType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Map converter rejects an N input") + void transformTo_whenMapConverterWithNumberAttribute_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = + provider.converterFor(EnhancedType.mapOf(String.class, Integer.class)); + + assertThatThrownBy(() -> converter.transformTo(AttributeValue.fromN("1"))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute." + + "MapAttributeConverter cannot convert an attribute of type N into the requested type " + + "interface java.util.Map"); + } + + @Test + @DisplayName("List converter rejects an M input") + void transformTo_whenListConverterWithEmptyMapAttribute_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.listOf(String.class)); + + assertThatThrownBy(() -> converter.transformTo(AttributeValue.fromM(Collections.emptyMap()))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute." + + "ListAttributeConverter cannot convert an attribute of type M into the requested type " + + "interface java.util.List"); + } + + @Test + @DisplayName("Set converter rejects a BOOL input") + void transformTo_whenSetConverterWithBooleanAttribute_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.setOf(String.class)); + + assertThatThrownBy(() -> converter.transformTo(AttributeValue.fromBool(true))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute." + + "SetAttributeConverter cannot convert an attribute of type BOOL into the requested type " + + "interface java.util.Set"); + } + + @Test + @DisplayName("List converter also reads SS input") + void transformTo_whenListConverterWithStringSetAttribute_returnsArrayListContainingValuesInOrder() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.listOf(String.class)); + + List converted = converter.transformTo(AttributeValue.fromSs(Arrays.asList("a", "b"))); + + assertThat(converted).isInstanceOf(ArrayList.class).containsExactly("a", "b"); + } + + @Test + @DisplayName("Set converter also reads L input") + void transformTo_whenSetConverterWithListAttribute_returnsLinkedHashSetContainingValuesInOrder() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.setOf(String.class)); + AttributeValue input = AttributeValue.fromL(Arrays.asList(AttributeValue.fromS("a"), + AttributeValue.fromS("b"))); + + Set converted = converter.transformTo(input); + + assertThat(converted).isInstanceOf(LinkedHashSet.class).containsExactly("a", "b"); + } + + @Test + @DisplayName("Document converter passes a wrong input type as an empty map") + void transformTo_whenDocumentConverterWithNumberAttribute_readsEmptyAutoConstructedMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + RecordingDocumentSchema schema = new RecordingDocumentSchema(); + EnhancedType type = EnhancedType.documentOf(DocumentType.class, schema); + AttributeValue input = AttributeValue.fromN("1"); + + DocumentType converted = provider.converterFor(type).transformTo(input); + + assertThat(input.m()).isEmpty(); + assertThat(schema.lastMapToItemMap()).isSameAs(input.m()); + assertThat(schema.lastMapToItemPreserveEmptyObject()).isFalse(); + assertThat(converted).isNull(); + } + + @Test + @DisplayName("Integer converter exposes its current wrong-type visitor target") + void transformTo_whenIntegerConverterWithBooleanAttribute_throwsIllegalStateExceptionForInstant() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = provider.converterFor(EnhancedType.of(Integer.class)); + + assertThatThrownBy(() -> converter.transformTo(AttributeValue.fromBool(true))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute." + + "IntegerAttributeConverter cannot convert an attribute of type BOOL into the requested type " + + "class java.time.Instant"); + } + + @Test + @DisplayName("Enum converter rejects a non-string input") + void transformTo_whenEnumConverterWithNumberAttribute_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = provider.converterFor(EnhancedType.of(TestEnum.class)); + + assertThatThrownBy(() -> converter.transformTo(AttributeValue.fromN("1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot convert non-string value to enum."); + } + + @Test + @DisplayName("Enum converter rejects unknown text") + void transformTo_whenEnumConverterWithUnknownText_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = provider.converterFor(EnhancedType.of(TestEnum.class)); + + assertThatThrownBy(() -> converter.transformTo(AttributeValue.fromS("missing"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unable to convert string value 'missing' to enum type '" + TestEnum.class + "'"); + } + + @Test + @DisplayName("Generated enum converter persists toString()") + void transformFrom_whenLowercaseEnumOpen_roundTripsOpenString() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = + provider.converterFor(EnhancedType.of(LowercaseEnum.class)); + + assertThat(LowercaseEnum.OPEN.toString()).isEqualTo("open"); + assertThat(converter).isInstanceOf(EnumAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(converter.transformFrom(LowercaseEnum.OPEN).s()).isEqualTo("open"); + assertThat(converter.transformTo(AttributeValue.fromS("open"))).isEqualTo(LowercaseEnum.OPEN); + } + + @Test + @DisplayName("Enum name text is not accepted when toString() differs") + void transformTo_whenLowercaseEnumWithNameText_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = + provider.converterFor(EnhancedType.of(LowercaseEnum.class)); + + assertThatThrownBy(() -> converter.transformTo(AttributeValue.fromS("OPEN"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unable to convert string value 'OPEN' to enum type '" + LowercaseEnum.class + "'"); + } + + @Test + @DisplayName("String converter maps null Java input to NULL") + void transformFrom_whenStringConverterWithNullInput_returnsNullAttribute() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = provider.converterFor(EnhancedType.of(String.class)); + + assertThat(converter.transformFrom(null)).isEqualTo(AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("Map converter rejects null Java input") + void transformFrom_whenMapConverterWithNullInput_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = + provider.converterFor(EnhancedType.mapOf(String.class, Integer.class)); + + assertThatThrownBy(() -> converter.transformFrom(null)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("Set converter rejects null Java input") + void transformFrom_whenSetConverterWithNullInput_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.setOf(String.class)); + + assertThatThrownBy(() -> converter.transformFrom(null)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("List converter rejects null Java input") + void transformFrom_whenListConverterWithNullInput_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.listOf(String.class)); + + assertThatThrownBy(() -> converter.transformFrom(null)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("Enum converter rejects null Java input") + void transformFrom_whenEnumConverterWithNullInput_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = provider.converterFor(EnhancedType.of(TestEnum.class)); + + assertThatThrownBy(() -> converter.transformFrom(null)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("Map converter rejects null AttributeValue input") + void transformTo_whenMapConverterWithNullAttribute_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = + provider.converterFor(EnhancedType.mapOf(String.class, Integer.class)); + + assertThatThrownBy(() -> converter.transformTo(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Generated attribute value must not contain null values. Use AttributeValue#nul() instead."); + } + + @Test + @DisplayName("List converter rejects null AttributeValue input") + void transformTo_whenListConverterWithNullAttribute_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.listOf(String.class)); + + assertThatThrownBy(() -> converter.transformTo(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Generated attribute value must not contain null values. Use AttributeValue#nul() instead."); + } + + @Test + @DisplayName("Set converter rejects null AttributeValue input") + void transformTo_whenSetConverterWithNullAttribute_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.setOf(String.class)); + + assertThatThrownBy(() -> converter.transformTo(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Generated attribute value must not contain null values. Use AttributeValue#nul() instead."); + } + + @Test + @DisplayName("Map converter reads DynamoDB NULL as Java null") + void transformTo_whenMapConverterWithNullAttributeValue_returnsNull() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = + provider.converterFor(EnhancedType.mapOf(String.class, Integer.class)); + + assertThat(converter.transformTo(AttributeValue.fromNul(true))).isNull(); + } + + @Test + @DisplayName("List converter reads DynamoDB NULL as Java null") + void transformTo_whenListConverterWithNullAttributeValue_returnsNull() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.listOf(String.class)); + + assertThat(converter.transformTo(AttributeValue.fromNul(true))).isNull(); + } + + @Test + @DisplayName("Set converter reads DynamoDB NULL as Java null") + void transformTo_whenSetConverterWithNullAttributeValue_returnsNull() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = provider.converterFor(EnhancedType.setOf(String.class)); + + assertThat(converter.transformTo(AttributeValue.fromNul(true))).isNull(); + } + + @Test + @DisplayName("Primitive optional reads DynamoDB NULL as empty") + void transformTo_whenOptionalIntConverterWithNullAttributeValue_returnsEmpty() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = provider.converterFor(EnhancedType.of(OptionalInt.class)); + + assertThat(converter.transformTo(AttributeValue.fromNul(true))).isEqualTo(OptionalInt.empty()); + } + + @Test + @DisplayName("Map value converter write failure propagates") + void transformFrom_whenMapOfThrowingType_throwsSameValueWriteException() { + ThrowingTypeConverter throwingConverter = + new ThrowingTypeConverter("Map value converter failed while writing ThrowingType", "unused read message"); + DefaultAttributeConverterProvider provider = providerWith(throwingConverter); + Map input = Collections.singletonMap("key", new ThrowingType()); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.mapOf(String.class, ThrowingType.class)) + .transformFrom(input)) + .isSameAs(throwingConverter.fromFailure()); + } + + @Test + @DisplayName("Map value converter read failure propagates") + void transformTo_whenMapOfThrowingType_throwsSameValueReadException() { + ThrowingTypeConverter throwingConverter = + new ThrowingTypeConverter("unused write message", "Map value converter failed while reading ThrowingType"); + DefaultAttributeConverterProvider provider = providerWith(throwingConverter); + AttributeValue mapAttribute = + AttributeValue.fromM(Collections.singletonMap("key", AttributeValue.fromS("x"))); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.mapOf(String.class, ThrowingType.class)) + .transformTo(mapAttribute)) + .isSameAs(throwingConverter.toFailure()); + } + + @Test + @DisplayName("List member converter write failure propagates") + void transformFrom_whenListOfThrowingType_throwsSameMemberWriteException() { + ThrowingTypeConverter throwingConverter = + new ThrowingTypeConverter("List member converter failed while writing ThrowingType", "unused read message"); + DefaultAttributeConverterProvider provider = providerWith(throwingConverter); + List input = Collections.singletonList(new ThrowingType()); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.listOf(ThrowingType.class)) + .transformFrom(input)) + .isSameAs(throwingConverter.fromFailure()); + } + + @Test + @DisplayName("List member converter read failure propagates") + void transformTo_whenListOfThrowingType_throwsSameMemberReadException() { + ThrowingTypeConverter throwingConverter = + new ThrowingTypeConverter("unused write message", "List member converter failed while reading ThrowingType"); + DefaultAttributeConverterProvider provider = providerWith(throwingConverter); + AttributeValue listAttribute = + AttributeValue.fromL(Collections.singletonList(AttributeValue.fromS("x"))); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.listOf(ThrowingType.class)) + .transformTo(listAttribute)) + .isSameAs(throwingConverter.toFailure()); + } + + @Test + @DisplayName("Set member converter write failure propagates") + void transformFrom_whenSetOfThrowingType_throwsSameSetWriteException() { + ThrowingTypeConverter throwingConverter = + new ThrowingTypeConverter("Set member converter failed while writing ThrowingType", "unused read message"); + DefaultAttributeConverterProvider provider = providerWith(throwingConverter); + Set input = Collections.singleton(new ThrowingType()); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.setOf(ThrowingType.class)) + .transformFrom(input)) + .isSameAs(throwingConverter.fromFailure()); + } + + @Test + @DisplayName("Set member converter read failure propagates") + void transformTo_whenSetOfThrowingType_throwsSameSetReadException() { + ThrowingTypeConverter throwingConverter = + new ThrowingTypeConverter("unused write message", "Set member converter failed while reading ThrowingType"); + DefaultAttributeConverterProvider provider = providerWith(throwingConverter); + AttributeValue stringSetAttribute = AttributeValue.fromSs(Collections.singletonList("x")); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.setOf(ThrowingType.class)) + .transformTo(stringSetAttribute)) + .isSameAs(throwingConverter.toFailure()); + } + + @Test + @DisplayName("Set member declaring S but returning NULL fails while flattening") + void transformFrom_whenSetOfMisreportingStringMember_throwsAttributeValueMustBeS() { + DefaultAttributeConverterProvider provider = providerWith(new MisreportingStringMemberConverter()); + EnhancedType> type = EnhancedType.setOf(MisreportingStringMember.class); + Set input = Collections.singleton(new MisreportingStringMember()); + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThatThrownBy(() -> converter.transformFrom(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute value must be S."); + } + + @Test + @DisplayName("Document schema write failure propagates") + void transformFrom_whenDocumentConverterItemToMapThrows_throwsSameSchemaWriteException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema throwingWriteSchema = throwingWriteSchema(); + EnhancedType type = + EnhancedType.documentOf(ThrowingWriteDocument.class, throwingWriteSchema); + + assertThatThrownBy(() -> provider.converterFor(type).transformFrom(new ThrowingWriteDocument())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Document schema failed while converting ThrowingWriteDocument to a map"); + } + + @Test + @DisplayName("Document schema read failure propagates") + void transformTo_whenDocumentConverterMapToItemThrows_throwsSameSchemaReadException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema throwingReadSchema = throwingReadSchema(); + EnhancedType type = + EnhancedType.documentOf(ThrowingReadDocument.class, throwingReadSchema); + AttributeValue mapAttribute = + AttributeValue.fromM(Collections.singletonMap("name", AttributeValue.fromS("x"))); + + assertThatThrownBy(() -> provider.converterFor(type).transformTo(mapAttribute)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Document schema failed while converting a map to ThrowingReadDocument"); + } + + @Test + @DisplayName("Document converter delegates null Java input to its schema") + void transformFrom_whenDocumentConverterWithNullInput_recordsNullAndFalseAndStoresEmptyMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + RecordingDocumentSchema schema = new RecordingDocumentSchema(); + EnhancedType type = EnhancedType.documentOf(DocumentType.class, schema); + + AttributeValue stored = provider.converterFor(type).transformFrom(null); + + assertThat(schema.lastItemToMapItem()).isNull(); + assertThat(schema.lastItemToMapIgnoreNulls()).isFalse(); + assertThat(stored.hasM()).isTrue(); + assertThat(stored.m()).isEmpty(); + } + + @Test + @DisplayName("Integer converter rejects null Java input") + void transformFrom_whenIntegerConverterWithNullInput_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter converter = provider.converterFor(EnhancedType.of(Integer.class)); + + assertThatThrownBy(() -> converter.transformFrom(null)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("Null integer map value propagates member-converter failure") + void transformFrom_whenStringToIntegerMapWithNullValue_throwsNullPointerException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + AttributeConverter> converter = + provider.converterFor(EnhancedType.mapOf(String.class, Integer.class)); + Map input = new HashMap<>(); + input.put("key", null); + + assertThatThrownBy(() -> converter.transformFrom(input)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("Document converter forwards ignoreNulls") + void transformFrom_whenDocumentConverterWithIgnoreNulls_invokesItemToMapWithTrueAndWrapsMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + RecordingDocumentSchema schema = new RecordingDocumentSchema(); + Map schemaMap = + Collections.singletonMap("name", AttributeValue.fromS("doc")); + schema.setItemToMapResult(schemaMap); + DocumentType input = new DocumentType(); + EnhancedType type = + EnhancedType.documentOf(DocumentType.class, schema, b -> b.ignoreNulls(true)); + + AttributeValue stored = provider.converterFor(type).transformFrom(input); + + assertThat(schema.lastItemToMapItem()).isSameAs(input); + assertThat(schema.lastItemToMapIgnoreNulls()).isTrue(); + assertThat(stored.hasM()).isTrue(); + assertThat(stored.m()).isEqualTo(schemaMap); + } + + @Test + @DisplayName("Document converter forwards preserveEmptyObject") + void transformTo_whenDocumentConverterWithPreserveEmptyObject_invokesMapToItemWithTrue() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + RecordingDocumentSchema schema = new RecordingDocumentSchema(); + DocumentType reconstructed = new DocumentType(); + schema.setMapToItemResult(reconstructed); + AttributeValue input = AttributeValue.fromM(Collections.emptyMap()); + EnhancedType type = + EnhancedType.documentOf(DocumentType.class, schema, b -> b.preserveEmptyObject(true)); + + DocumentType converted = provider.converterFor(type).transformTo(input); + + assertThat(schema.lastMapToItemMap()).isSameAs(input.m()); + assertThat(schema.lastMapToItemPreserveEmptyObject()).isTrue(); + assertThat(converted).isSameAs(reconstructed); + } + + @Test + @DisplayName("Null AttributeValue from a list member converter is rejected") + void transformFrom_whenListWithNullReturningMember_throwsListMustNotHaveNullValues() { + DefaultAttributeConverterProvider provider = providerWith(new NullReturningMemberConverter()); + List input = Collections.singletonList(new NullReturningMember()); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.listOf(NullReturningMember.class)) + .transformFrom(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("List must not have null values."); + } + + private static DefaultAttributeConverterProvider providerWith(AttributeConverter converter) { + return DefaultAttributeConverterProvider.builder().addConverter(converter).build(); + } + + private static TableSchema throwingWriteSchema() { + return StaticTableSchema.builder(ThrowingWriteDocument.class) + .newItemSupplier(ThrowingWriteDocument::new) + .addAttribute(String.class, a -> a.name("name") + .getter(ThrowingWriteDocument::getName) + .setter(ThrowingWriteDocument::setName)) + .build(); + } + + private static TableSchema throwingReadSchema() { + return StaticTableSchema.builder(ThrowingReadDocument.class) + .newItemSupplier(ThrowingReadDocument::new) + .addAttribute(String.class, a -> a.name("name") + .getter(ThrowingReadDocument::getName) + .setter(ThrowingReadDocument::setName)) + .build(); + } + + private static Type wildcardTypeArgument(String fieldName) { + try { + Type genericType = WildcardHolder.class.getDeclaredField(fieldName).getGenericType(); + return ((ParameterizedType) genericType).getActualTypeArguments()[0]; + } catch (NoSuchFieldException e) { + throw new IllegalStateException(e); + } + } + + private static Type genericHolderFieldType(String fieldName) { + try { + return GenericHolder.class.getDeclaredField(fieldName).getGenericType(); + } catch (NoSuchFieldException e) { + throw new IllegalStateException(e); + } + } + + private static class WildcardHolder { + List unbounded; + List upperBounded; + List lowerBounded; + } + + private static class GenericHolder { + T typeVariable; + T[] genericArray; + } + + static class Envelope { + } + + interface UnsupportedInterface { + } + + abstract static class AbstractUnsupportedType { + } + + static class DocumentType { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + static class ThrowingWriteDocument { + public String getName() { + throw new IllegalArgumentException("Document schema failed while converting ThrowingWriteDocument to a map"); + } + + public void setName(String name) { + } + } + + static class ThrowingReadDocument { + public String getName() { + return null; + } + + public void setName(String name) { + throw new IllegalArgumentException("Document schema failed while converting a map to ThrowingReadDocument"); + } + } + + static class ThrowingType { + } + + static class MisreportingStringMember { + } + + static class NullReturningMember { + } + + enum TestEnum { + OPEN, + CLOSED + } + + enum LowercaseEnum { + OPEN, + CLOSED; + + @Override + public String toString() { + return name().toLowerCase(Locale.ROOT); + } + } + + private static final class ThrowingTypeConverter implements AttributeConverter { + private final IllegalArgumentException fromFailure; + private final IllegalArgumentException toFailure; + + ThrowingTypeConverter(String fromMessage, String toMessage) { + this.fromFailure = new IllegalArgumentException(fromMessage); + this.toFailure = new IllegalArgumentException(toMessage); + } + + IllegalArgumentException fromFailure() { + return fromFailure; + } + + IllegalArgumentException toFailure() { + return toFailure; + } + + @Override + public AttributeValue transformFrom(ThrowingType input) { + throw fromFailure; + } + + @Override + public ThrowingType transformTo(AttributeValue input) { + throw toFailure; + } + + @Override + public EnhancedType type() { + return EnhancedType.of(ThrowingType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + private static final class MisreportingStringMemberConverter + implements AttributeConverter { + + @Override + public AttributeValue transformFrom(MisreportingStringMember input) { + return AttributeValue.fromNul(true); + } + + @Override + public MisreportingStringMember transformTo(AttributeValue input) { + throw new UnsupportedOperationException(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(MisreportingStringMember.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + private static final class RecordingDocumentSchema implements TableSchema { + private DocumentType lastItemToMapItem; + private Boolean lastItemToMapIgnoreNulls; + private Map itemToMapResult = Collections.emptyMap(); + private Map lastMapToItemMap; + private Boolean lastMapToItemPreserveEmptyObject; + private DocumentType mapToItemResult; + + void setItemToMapResult(Map itemToMapResult) { + this.itemToMapResult = itemToMapResult; + } + + void setMapToItemResult(DocumentType mapToItemResult) { + this.mapToItemResult = mapToItemResult; + } + + DocumentType lastItemToMapItem() { + return lastItemToMapItem; + } + + Boolean lastItemToMapIgnoreNulls() { + return lastItemToMapIgnoreNulls; + } + + Map lastMapToItemMap() { + return lastMapToItemMap; + } + + Boolean lastMapToItemPreserveEmptyObject() { + return lastMapToItemPreserveEmptyObject; + } + + @Override + public DocumentType mapToItem(Map attributeMap) { + throw new UnsupportedOperationException(); + } + + @Override + public DocumentType mapToItem(Map attributeMap, boolean preserveEmptyObject) { + lastMapToItemMap = attributeMap; + lastMapToItemPreserveEmptyObject = preserveEmptyObject; + return mapToItemResult; + } + + @Override + public Map itemToMap(DocumentType item, boolean ignoreNulls) { + lastItemToMapItem = item; + lastItemToMapIgnoreNulls = ignoreNulls; + return itemToMapResult; + } + + @Override + public Map itemToMap(DocumentType item, Collection attributes) { + throw new UnsupportedOperationException(); + } + + @Override + public AttributeValue attributeValue(DocumentType item, String attributeName) { + throw new UnsupportedOperationException(); + } + + @Override + public TableMetadata tableMetadata() { + throw new UnsupportedOperationException(); + } + + @Override + public EnhancedType itemType() { + return EnhancedType.of(DocumentType.class); + } + + @Override + public List attributeNames() { + return Collections.emptyList(); + } + + @Override + public boolean isAbstract() { + return false; + } + } + + private static final class NullReturningMemberConverter implements AttributeConverter { + @Override + public AttributeValue transformFrom(NullReturningMember input) { + return null; + } + + @Override + public NullReturningMember transformTo(AttributeValue input) { + throw new UnsupportedOperationException(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(NullReturningMember.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderListTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderListTest.java new file mode 100644 index 000000000000..dfe3b3004853 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderListTest.java @@ -0,0 +1,422 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Stack; +import java.util.Vector; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests generated converters for list types and nested list values. + *

+ * The tests verify ordering, duplicate and null members, empty lists, scalar members, maps, sets, and schema backed + * documents. They also cover recursive lookup failures and unsupported concrete or parameterized list declarations. + */ +public class DefaultAttributeConverterProviderListTest { + + @Test + @DisplayName("List conversion preserves order and duplicates as an ArrayList") + void converterFor_whenStringList_preservesOrderAndDuplicatesAsArrayList() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.listOf(String.class); + List input = new ArrayList<>(); + input.add("a"); + input.add("b"); + input.add("a"); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(read).isInstanceOf(ArrayList.class) + .isEqualTo(input) + .containsExactly("a", "b", "a"); + } + + @Test + @DisplayName("Empty list conversion stores an empty L and reads an empty ArrayList") + void converterFor_whenEmptyStringList_convertsToEmptyLAndReadsEmptyArrayList() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.listOf(String.class); + List input = new ArrayList<>(); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).isEmpty(); + assertThat(read).isInstanceOf(ArrayList.class) + .isEmpty(); + } + + @Test + @DisplayName("A null string list member is stored as DynamoDB NULL and read back as null") + void converterFor_whenStringListWithNullMember_storesNulAndReadsNullMember() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.listOf(String.class); + List input = new ArrayList<>(); + input.add("a"); + input.add(null); + input.add("b"); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).hasSize(3); + assertThat(stored.l().get(1).nul()).isTrue(); + assertThat(read).isInstanceOf(ArrayList.class) + .containsExactly("a", null, "b"); + assertThat(read.get(1)).isNull(); + } + + @Test + @DisplayName("An Instant list member is stored as S and read as an equal ArrayList") + void converterFor_whenInstantList_convertsMemberAsSAndReadsEqualArrayList() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.listOf(Instant.class); + Instant instant = Instant.parse("2020-01-02T03:04:05Z"); + List input = new ArrayList<>(); + input.add(instant); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).hasSize(1); + assertThat(stored.l().get(0).s()).isNotNull(); + assertThat(read).isInstanceOf(ArrayList.class) + .isEqualTo(input) + .containsExactly(instant); + } + + @Test + @DisplayName("An enum list member is stored as S and read as the same constant") + void converterFor_whenEnumList_convertsMemberAsSAndReadsSameConstant() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.listOf(TestEnum.class); + List input = new ArrayList<>(); + input.add(TestEnum.OPEN); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).hasSize(1); + assertThat(stored.l().get(0).s()).isEqualTo(TestEnum.OPEN.toString()); + assertThat(read).isInstanceOf(ArrayList.class) + .containsExactly(TestEnum.OPEN); + } + + @Test + @DisplayName("A document list member is stored as M and reconstructed through the table schema") + void converterFor_whenDocumentList_recordsBothConversionsAndReadsReconstructedDocument() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema schema = documentSchema(); + EnhancedType> type = + EnhancedType.listOf(EnhancedType.documentOf(DocumentType.class, schema)); + DocumentType document = new DocumentType(); + document.setName("doc"); + List input = new ArrayList<>(); + input.add(document); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).hasSize(1); + assertThat(stored.l().get(0).m()).containsEntry("name", AttributeValue.fromS("doc")); + assertThat(read).isInstanceOf(ArrayList.class); + assertThat(read.get(0).getName()).isEqualTo("doc"); + } + + @Test + @DisplayName("A nested list uses L at both levels and reads ArrayList at both levels") + void converterFor_whenNestedStringList_usesLAtBothLevelsAndReadsArrayLists() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = EnhancedType.listOf(EnhancedType.listOf(String.class)); + List inner = new ArrayList<>(); + inner.add("a"); + inner.add("b"); + List> input = new ArrayList<>(); + input.add(inner); + + AttributeConverter>> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List> read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).hasSize(1); + assertThat(stored.l().get(0).l()).containsExactly(AttributeValue.fromS("a"), + AttributeValue.fromS("b")); + assertThat(read).isInstanceOf(ArrayList.class); + assertThat(read.get(0)).isInstanceOf(ArrayList.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("A map list member uses L then M and reads ArrayList then LinkedHashMap") + void converterFor_whenMapListMember_usesLThenMAndReadsArrayListThenLinkedHashMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = + EnhancedType.listOf(EnhancedType.mapOf(String.class, Integer.class)); + Map inner = new LinkedHashMap<>(); + inner.put("one", 1); + List> input = new ArrayList<>(); + input.add(inner); + + AttributeConverter>> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List> read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).hasSize(1); + assertThat(stored.l().get(0).m()).isNotNull(); + assertThat(read).isInstanceOf(ArrayList.class); + assertThat(read.get(0)).isInstanceOf(LinkedHashMap.class) + .containsEntry("one", 1); + } + + @Test + @DisplayName("A set list member uses L then SS and reads ArrayList then LinkedHashSet") + void converterFor_whenSetListMember_usesLThenSsAndReadsArrayListThenLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = EnhancedType.listOf(EnhancedType.setOf(String.class)); + Set inner = new LinkedHashSet<>(); + inner.add("a"); + inner.add("b"); + List> input = new ArrayList<>(); + input.add(inner); + + AttributeConverter>> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List> read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).hasSize(1); + assertThat(stored.l().get(0).ss()).containsExactly("a", "b"); + assertThat(read).isInstanceOf(ArrayList.class); + assertThat(read.get(0)).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("A list of maps of documents uses L, M, then M and reconstructs the document") + void converterFor_whenListOfMapsOfDocuments_usesLThenMThenMAndReadsReconstructedDocument() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema schema = documentSchema(); + EnhancedType>> type = + EnhancedType.listOf(EnhancedType.mapOf(EnhancedType.of(String.class), + EnhancedType.documentOf(DocumentType.class, schema))); + DocumentType nested = new DocumentType(); + nested.setName("doc"); + Map inner = new LinkedHashMap<>(); + inner.put("doc", nested); + List> input = new ArrayList<>(); + input.add(inner); + + AttributeConverter>> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + List> read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(stored.l()).hasSize(1); + assertThat(stored.l().get(0).m()).isNotNull(); + assertThat(stored.l().get(0).m().get("doc").m()).containsEntry("name", AttributeValue.fromS("doc")); + assertThat(read).isInstanceOf(ArrayList.class); + assertThat(read.get(0)).isInstanceOf(LinkedHashMap.class); + assertThat(read.get(0).get("doc").getName()).isEqualTo("doc"); + } + + @Test + @DisplayName("An Object list member fails converter lookup") + void converterFor_whenObjectListMember_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.listOf(Object.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("A document class without a schema is an unsupported list member") + void converterFor_whenDocumentClassWithoutSchemaList_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.listOf(DocumentType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A wildcard list member fails while reading the raw class") + void converterFor_whenWildcardListMember_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A wildcard type is not expected here."); + } + + @Test + @DisplayName("A nested Object list fails converter lookup for the inner Object") + void converterFor_whenNestedObjectList_throwsConverterNotFoundForInnerObject() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> innerType = EnhancedType.listOf(Object.class); + EnhancedType>> type = EnhancedType.listOf(innerType); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("A nested Boolean set rejects a BOOL member converter") + void converterFor_whenNestedBooleanSetList_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = EnhancedType.listOf(EnhancedType.setOf(Boolean.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SetAttributeConverter cannot be created with a parameterized type of 'class java.lang.Boolean'. " + + "Supported parameterized types must convert to B, S or N DynamoDB AttributeValues."); + } + + @Test + @DisplayName("A captured LinkedList is currently unsupported") + void converterFor_whenLinkedList_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A named custom list implementation is currently unsupported") + void converterFor_whenCustomList_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured Vector is currently unsupported") + void converterFor_whenVector_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured CopyOnWriteArrayList is currently unsupported") + void converterFor_whenCopyOnWriteArrayList_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured Stack is currently unsupported") + void converterFor_whenStack_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + private static TableSchema documentSchema() { + return StaticTableSchema.builder(DocumentType.class) + .newItemSupplier(DocumentType::new) + .addAttribute(String.class, a -> a.name("name") + .getter(DocumentType::getName) + .setter(DocumentType::setName)) + .build(); + } + + static class DocumentType { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + enum TestEnum { + OPEN, + CLOSED + } + + static final class CustomList extends ArrayList { + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderLookupTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderLookupTest.java new file mode 100644 index 000000000000..16441f7da8f5 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderLookupTest.java @@ -0,0 +1,551 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DocumentAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; + +/** + * Tests converter selection for registered, generated, document, and unsupported types. + *

+ * The tests exercise maps, sets, lists, collections, iterables, enumerations, and schema backed documents. They verify + * lookup precedence, cache eligibility, recursive member lookup, malformed type tokens, raw types, and unsupported + * concrete collection declarations. + */ +public class DefaultAttributeConverterProviderLookupTest { + + private DefaultAttributeConverterProvider provider; + + @BeforeEach + void setUp() { + provider = new DefaultAttributeConverterProvider(); + } + + @Test + @DisplayName("Null EnhancedType fails during cache lookup") + void converterFor_whenNullType_throwsNullPointerException() { + assertThatThrownBy(() -> provider.converterFor(null)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("Registered String type returns the cached string converter") + void converterFor_whenRegisteredStringType_returnsCachedStringAttributeConverter() { + EnhancedType type = EnhancedType.of(String.class); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(StringAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Unbounded wildcard type fails while reading the raw class") + void converterFor_whenUnboundedWildcardType_throwsIllegalArgumentException() { + EnhancedType type = EnhancedType.of(unboundedWildcardType()); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A wildcard type is not expected here."); + } + + @Test + @DisplayName("Document token with a null raw class fails during assignability") + void converterFor_whenDocumentTokenWithNullRawClass_throwsNullPointerException() { + TableSchema schema = mock(TableSchema.class); + EnhancedType type = EnhancedType.documentOf((Class) null, schema); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + verifyNoInteractions(schema); + } + + @Test + @DisplayName("Map of String to Integer selects and caches a map converter") + void converterFor_whenStringToIntegerMap_returnsCachedMapAttributeConverter() { + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Raw Map type fails converter lookup") + void converterFor_whenRawMapType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(Map.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.Map). Type parameters are required for this type."); + } + + @Test + @DisplayName("A Map document token fails while reading map parameters before the schema is read") + void converterFor_whenMapDocumentToken_throwsNullPointerExceptionBeforeReadingSchema() { + TableSchema mapSchema = mock(TableSchema.class); + EnhancedType type = EnhancedType.documentOf(Map.class, mapSchema); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.Map). Type parameters are required for this type."); + verifyNoInteractions(mapSchema); + } + + @Test + @DisplayName("Map with an unsupported key type fails during string-key lookup") + void converterFor_whenMapWithUnsupportedKey_throwsIllegalArgumentException() { + EnhancedType> type = EnhancedType.mapOf(UnsupportedKey.class, String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for " + UnsupportedKey.class); + } + + @Test + @DisplayName("Map with an unsupported value type fails during recursive lookup") + void converterFor_whenMapWithUnsupportedValue_throwsIllegalStateException() { + EnhancedType> type = EnhancedType.mapOf(String.class, UnsupportedType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Schema-bearing Map token replaces the generated map converter") + void converterFor_whenSchemaBearingMapType_returnsCachedDocumentAttributeConverter() { + TableSchema> mapSchema = unusedMapSchema(); + EnhancedType> type = new EnhancedType>() { + @Override + public Optional>> tableSchema() { + return Optional.of(mapSchema); + } + }; + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(DocumentAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Plain Object type fails converter lookup") + void converterFor_whenObjectType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(Object.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A schema-bearing Object document token fails while reading the schema before the schema is read") + void converterFor_whenObjectDocumentToken_throwsIllegalStateExceptionBeforeReadingSchema() { + TableSchema objectSchema = mock(TableSchema.class); + EnhancedType type = EnhancedType.documentOf(Object.class, objectSchema); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + verifyNoInteractions(objectSchema); + } + + @Test + @DisplayName("Set of String selects and caches a string-set converter") + void converterFor_whenStringSet_returnsCachedSetAttributeConverter() { + EnhancedType> type = EnhancedType.setOf(String.class); + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Raw Set type fails converter lookup") + void converterFor_whenRawSetType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(Set.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.Set). Type parameters are required for this type."); + } + + @Test + @DisplayName("A Set document token fails while reading set parameters before the schema is read") + void converterFor_whenSetDocumentToken_throwsNullPointerExceptionBeforeReadingSchema() { + TableSchema setSchema = mock(TableSchema.class); + EnhancedType type = EnhancedType.documentOf(Set.class, setSchema); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.Set). Type parameters are required for this type."); + verifyNoInteractions(setSchema); + } + + @Test + @DisplayName("Set with an unsupported member type fails during recursive lookup") + void converterFor_whenSetWithUnsupportedMember_throwsIllegalStateException() { + EnhancedType> type = EnhancedType.setOf(UnsupportedType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Set of Boolean is rejected because BOOL is not a set member type") + void converterFor_whenBooleanSet_throwsIllegalArgumentException() { + EnhancedType> type = EnhancedType.setOf(Boolean.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("SetAttributeConverter cannot be created with a parameterized type of 'class java.lang.Boolean'. " + + "Supported parameterized types must convert to B, S or N DynamoDB AttributeValues."); + } + + @Test + @DisplayName("Schema-bearing Set token replaces the generated set converter") + void converterFor_whenSchemaBearingSetType_returnsCachedDocumentAttributeConverter() { + TableSchema> setSchema = unusedSetSchema(); + EnhancedType> type = new EnhancedType>() { + @Override + public Optional>> tableSchema() { + return Optional.of(setSchema); + } + }; + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(DocumentAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Collection of String enters the set branch and caches a string-set converter") + void converterFor_whenStringCollection_returnsCachedSetAttributeConverter() { + EnhancedType> type = EnhancedType.collectionOf(String.class); + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.type()).isEqualTo(EnhancedType.setOf(String.class)); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Iterable of String enters the set branch and caches a string-set converter") + void converterFor_whenStringIterable_returnsCachedSetAttributeConverter() { + EnhancedType> type = new EnhancedType>() { + }; + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.type()).isEqualTo(EnhancedType.setOf(String.class)); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("List of String selects a list converter without caching it") + void converterFor_whenStringList_returnsUncachedListAttributeConverter() { + EnhancedType> type = EnhancedType.listOf(String.class); + + AttributeConverter> first = provider.converterFor(type); + AttributeConverter> second = provider.converterFor(type); + + assertThat(first).isInstanceOf(ListAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(second).isInstanceOf(ListAttributeConverter.class); + assertThat(second).isNotSameAs(first); + } + + @Test + @DisplayName("Raw List type fails converter lookup") + void converterFor_whenRawListType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(List.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.List). Type parameters are required for this type."); + } + + @Test + @DisplayName("A List document token fails while reading list parameters before the schema is read") + void converterFor_whenListDocumentToken_throwsIllegalStateExceptionBeforeReadingSchema() { + TableSchema listSchema = mock(TableSchema.class); + EnhancedType type = EnhancedType.documentOf(List.class, listSchema); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.List). Type parameters are required for this type."); + verifyNoInteractions(listSchema); + } + + @Test + @DisplayName("List with an unsupported member type fails during recursive lookup") + void converterFor_whenListWithUnsupportedMember_throwsIllegalStateException() { + EnhancedType> type = EnhancedType.listOf(UnsupportedType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Enum type selects an enum converter without caching it") + void converterFor_whenEnumType_returnsUncachedEnumAttributeConverter() { + EnhancedType type = EnhancedType.of(TestEnum.class); + + AttributeConverter first = provider.converterFor(type); + AttributeConverter second = provider.converterFor(type); + + assertThat(first).isInstanceOf(EnumAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(second).isInstanceOf(EnumAttributeConverter.class); + assertThat(second).isNotSameAs(first); + } + + @Test + @DisplayName("Enum type with a table schema still selects the enum converter") + void converterFor_whenEnumDocumentToken_returnsUncachedEnumAttributeConverter() { + TableSchema enumSchema = mock(TableSchema.class); + EnhancedType type = EnhancedType.documentOf(TestEnum.class, enumSchema); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(EnumAttributeConverter.class); + verifyNoInteractions(enumSchema); + assertThat(provider.converterFor(type)).isNotSameAs(converter); + } + + @Test + @DisplayName("Named document type selects and caches a document converter") + void converterFor_whenNamedDocumentType_returnsCachedDocumentAttributeConverter() { + TableSchema documentSchema = unusedSchema(DocumentType.class, DocumentType::new); + EnhancedType type = EnhancedType.documentOf(DocumentType.class, documentSchema); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(DocumentAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Anonymous document class is not cached") + void converterFor_whenAnonymousDocumentType_returnsUncachedDocumentAttributeConverter() { + DocumentType anonymousDocument = new DocumentType() { + }; + TableSchema schema = unusedSchema(DocumentType.class, DocumentType::new); + @SuppressWarnings("unchecked") + EnhancedType type = EnhancedType.documentOf((Class) anonymousDocument.getClass(), schema); + + AttributeConverter first = provider.converterFor(type); + AttributeConverter second = provider.converterFor(type); + + assertThat(first).isInstanceOf(DocumentAttributeConverter.class); + assertThat(first.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isInstanceOf(DocumentAttributeConverter.class); + assertThat(second.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(second).isNotSameAs(first); + } + + @Test + @DisplayName("Named concrete Map class with a schema reaches the document branch") + void converterFor_whenNamedCustomMapDocument_returnsCachedDocumentAttributeConverter() { + TableSchema customMapSchema = unusedSchema(CustomMap.class, CustomMap::new); + EnhancedType type = EnhancedType.documentOf(CustomMap.class, customMapSchema); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(DocumentAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Named concrete Set class with a schema reaches the document branch") + void converterFor_whenNamedCustomSetDocument_returnsCachedDocumentAttributeConverter() { + TableSchema customSetSchema = unusedSchema(CustomSet.class, CustomSet::new); + EnhancedType type = EnhancedType.documentOf(CustomSet.class, customSetSchema); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(DocumentAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Named concrete List class with a schema reaches the document branch") + void converterFor_whenNamedCustomListDocument_returnsCachedDocumentAttributeConverter() { + TableSchema customListSchema = unusedSchema(CustomList.class, CustomList::new); + EnhancedType type = EnhancedType.documentOf(CustomList.class, customListSchema); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(DocumentAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(provider.converterFor(type)).isSameAs(converter); + } + + @Test + @DisplayName("Named class without a converter or schema fails lookup") + void converterFor_whenUnsupportedType_throwsIllegalStateException() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Document token created with a null schema is treated as absent") + void converterFor_whenDocumentTokenWithNullSchema_throwsIllegalStateException() { + EnhancedType type = EnhancedType.documentOf(DocumentType.class, null); + + assertThat(type.tableSchema()).isEmpty(); + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Deque of String has no default converter") + void converterFor_whenStringDeque_throwsIllegalStateException() { + EnhancedType type = EnhancedType.dequeOf(String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Concrete HashMap declaration has no default converter") + void converterFor_whenHashMapType_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Concrete HashSet declaration has no default converter") + void converterFor_whenHashSetType_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Concrete ArrayList declaration has no default converter") + void converterFor_whenArrayListType_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + private static TableSchema unusedSchema(Class type, Supplier supplier) { + return StaticTableSchema.builder(type).newItemSupplier(supplier).build(); + } + + @SuppressWarnings("unchecked") + private static TableSchema> unusedMapSchema() { + return unusedSchema((Class>) (Class) Map.class, HashMap::new); + } + + @SuppressWarnings("unchecked") + private static TableSchema> unusedSetSchema() { + return unusedSchema((Class>) (Class) Set.class, HashSet::new); + } + + private static Type unboundedWildcardType() { + try { + return WildcardHolder.class.getDeclaredField("unbounded").getGenericType(); + } catch (NoSuchFieldException e) { + throw new IllegalStateException(e); + } + } + + private static class WildcardHolder { + List unbounded; + } + + static class UnsupportedType { + } + + static class UnsupportedKey { + } + + static class DocumentType { + } + + enum TestEnum { + OPEN, + CLOSED + } + + static class CustomMap extends HashMap { + } + + static class CustomSet extends HashSet { + } + + static class CustomList extends ArrayList { + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderMapTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderMapTest.java new file mode 100644 index 000000000000..dc0e9e682a34 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderMapTest.java @@ -0,0 +1,688 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.Instant; +import java.time.Year; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests generated converters for map types and nested map values. + *

+ * The tests verify key conversion through the string converter registry and value conversion for scalar, collection, + * map, and schema backed document types. They also cover null entries, malformed parameterized types, unsupported key + * and value types, and unsupported concrete map declarations. + */ +public class DefaultAttributeConverterProviderMapTest { + + @Test + @DisplayName("String-to-Integer map round-trips as a LinkedHashMap") + void converterFor_whenStringIntegerMap_roundTripsLinkedHashMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + Map input = new LinkedHashMap<>(); + input.put("one", 1); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m().get("one").n()).isEqualTo("1"); + assertThat(read).isInstanceOf(LinkedHashMap.class).isEqualTo(input); + } + + @Test + @DisplayName("An empty String-to-Integer map round-trips as an empty LinkedHashMap") + void converterFor_whenEmptyStringIntegerMap_roundTripsEmptyLinkedHashMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + Map input = Collections.emptyMap(); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m()).isEmpty(); + assertThat(read).isInstanceOf(LinkedHashMap.class).isEmpty(); + } + + @Test + @DisplayName("UUID keys and Instant values convert independently through the map converter") + void converterFor_whenUuidToInstantMap_roundTripsLinkedHashMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(UUID.class, Instant.class); + UUID uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + Instant instant = Instant.parse("2020-01-02T03:04:05Z"); + Map input = new LinkedHashMap<>(); + input.put(uuid, instant); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m()).hasSize(1); + assertThat(stored.m().get(uuid.toString()).s()).isEqualTo("2020-01-02T03:04:05Z"); + assertThat(read).isInstanceOf(LinkedHashMap.class).isEqualTo(input); + } + + @Test + @DisplayName("Year is a valid map key through the string-converter registry") + void converterFor_whenYearToStringMap_roundTripsLinkedHashMapWithYearKey() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(Year.class, String.class); + Year year = Year.of(2026); + Map input = new LinkedHashMap<>(); + input.put(year, "value"); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m()).containsOnlyKeys("2026"); + assertThat(read).isInstanceOf(LinkedHashMap.class); + assertThat(read).containsEntry(year, "value"); + } + + @Test + @DisplayName("Direct Year lookup has no attribute converter") + void converterFor_whenYearType_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(Year.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.time.Year)"); + } + + @Test + @DisplayName("SdkBytes is not a registered map-key string type") + void converterFor_whenSdkBytesKeyMap_throwsNoStringConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(SdkBytes.class, String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for class software.amazon.awssdk.core.SdkBytes"); + } + + @Test + @DisplayName("Enum generation does not apply to map keys") + void converterFor_whenEnumKeyMap_throwsNoStringConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(TestEnum.class, String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for " + TestEnum.class); + } + + @Test + @DisplayName("An attribute-only custom converter cannot enable a map key") + void converterFor_whenAttributeOnlyKeyMap_throwsNoStringConverter() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(new AttributeOnlyKeyConverter()) + .addConverter(StringAttributeConverter.create()) + .build(); + EnhancedType> type = + EnhancedType.mapOf(AttributeOnlyKey.class, String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for " + AttributeOnlyKey.class); + } + + @Test + @DisplayName("Object map keys have no string converter") + void converterFor_whenObjectKeyMap_throwsNoStringConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(Object.class, String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for class java.lang.Object"); + } + + @Test + @DisplayName("A nested map token cannot be used as a map key") + void converterFor_whenMapTokenKey_throwsNoStringConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType, String>> type = + EnhancedType.mapOf(EnhancedType.mapOf(String.class, String.class), EnhancedType.of(String.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for interface java.util.Map"); + } + + @Test + @DisplayName("A list token cannot be used as a map key") + void converterFor_whenListTokenKey_throwsNoStringConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType, String>> type = + EnhancedType.mapOf(EnhancedType.listOf(String.class), EnhancedType.of(String.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for interface java.util.List"); + } + + @Test + @DisplayName("A set token cannot be used as a map key") + void converterFor_whenSetTokenKey_throwsNoStringConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType, String>> type = + EnhancedType.mapOf(EnhancedType.setOf(String.class), EnhancedType.of(String.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for interface java.util.Set"); + } + + @Test + @DisplayName("A document token cannot be used as a map key") + void converterFor_whenDocumentTokenKey_throwsNoStringConverter() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema documentSchema = mock(TableSchema.class); + EnhancedType> type = + EnhancedType.mapOf(EnhancedType.documentOf(DocumentType.class, documentSchema), + EnhancedType.of(String.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No string converter exists for " + DocumentType.class); + verifyNoInteractions(documentSchema); + } + + @Test + @DisplayName("A wildcard map key fails during raw-class validation") + void converterFor_whenWildcardKeyMap_throwsWildcardNotExpected() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A wildcard type is not expected here."); + } + + @Test + @DisplayName("An Object map value fails converter lookup") + void converterFor_whenObjectValueMap_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, Object.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("A document class without a schema is an unsupported map value") + void converterFor_whenDocumentValueWithoutSchema_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, DocumentType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A list-valued map preserves list order and duplicates") + void converterFor_whenListValuedMap_roundTripsLinkedHashMapWithArrayList() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = + EnhancedType.mapOf(EnhancedType.of(String.class), EnhancedType.listOf(String.class)); + List list = new ArrayList<>(); + list.add("a"); + list.add("a"); + Map> input = new LinkedHashMap<>(); + input.put("one", list); + + AttributeConverter>> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map> read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m().get("one").l()).extracting(AttributeValue::s).containsExactly("a", "a"); + assertThat(read).isInstanceOf(LinkedHashMap.class); + assertThat(read.get("one")).isInstanceOf(ArrayList.class).containsExactly("a", "a"); + } + + @Test + @DisplayName("A set-valued map stores a string set and reads a LinkedHashSet") + void converterFor_whenSetValuedMap_roundTripsLinkedHashMapWithLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = + EnhancedType.mapOf(EnhancedType.of(String.class), EnhancedType.setOf(String.class)); + Set set = new LinkedHashSet<>(); + set.add("a"); + set.add("b"); + Map> input = new LinkedHashMap<>(); + input.put("one", set); + + AttributeConverter>> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map> read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m().get("one").ss()).containsExactly("a", "b"); + assertThat(read).isInstanceOf(LinkedHashMap.class); + assertThat(read.get("one")).isInstanceOf(LinkedHashSet.class).containsExactly("a", "b"); + } + + @Test + @DisplayName("A map-valued map uses M at both levels and reads LinkedHashMap at both levels") + void converterFor_whenMapValuedMap_roundTripsNestedLinkedHashMaps() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = + EnhancedType.mapOf(EnhancedType.of(String.class), + EnhancedType.mapOf(String.class, Integer.class)); + Map inner = new LinkedHashMap<>(); + inner.put("one", 1); + Map> input = new LinkedHashMap<>(); + input.put("nested", inner); + + AttributeConverter>> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map> read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m().get("nested").m().get("one").n()).isEqualTo("1"); + assertThat(read).isInstanceOf(LinkedHashMap.class); + assertThat(read.get("nested")).isInstanceOf(LinkedHashMap.class).isEqualTo(inner); + } + + @Test + @DisplayName("An enum-valued map stores the constant toString as S") + void converterFor_whenEnumValuedMap_roundTripsLinkedHashMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, TestEnum.class); + Map input = new LinkedHashMap<>(); + input.put("state", TestEnum.OPEN); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m().get("state").s()).isEqualTo(TestEnum.OPEN.toString()); + assertThat(read).isInstanceOf(LinkedHashMap.class).containsEntry("state", TestEnum.OPEN); + } + + @Test + @DisplayName("A document-valued map delegates both conversions to the table schema") + void converterFor_whenDocumentValuedMap_invokesSchemaAndRoundTripsLinkedHashMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema schema = documentSchema(); + DocumentType inputDocument = new DocumentType(); + inputDocument.setName("doc"); + EnhancedType> type = + EnhancedType.mapOf(EnhancedType.of(String.class), + EnhancedType.documentOf(DocumentType.class, schema)); + Map input = new LinkedHashMap<>(); + input.put("doc", inputDocument); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m().get("doc").m()).containsEntry("name", AttributeValue.fromS("doc")); + assertThat(read).isInstanceOf(LinkedHashMap.class); + assertThat(read.get("doc").getName()).isEqualTo("doc"); + } + + @Test + @DisplayName("A map whose value is a list of sets nests M, L, then SS") + void converterFor_whenListOfSetsValuedMap_roundTripsNestedCollectionClasses() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>>> type = + EnhancedType.mapOf(EnhancedType.of(String.class), + EnhancedType.listOf(EnhancedType.setOf(String.class))); + Set set = new LinkedHashSet<>(); + set.add("a"); + set.add("b"); + List> list = new ArrayList<>(); + list.add(set); + Map>> input = new LinkedHashMap<>(); + input.put("one", list); + + AttributeConverter>>> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map>> read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m().get("one").l()).hasSize(1); + assertThat(stored.m().get("one").l().get(0).ss()).containsExactly("a", "b"); + assertThat(read).isInstanceOf(LinkedHashMap.class); + assertThat(read.get("one")).isInstanceOf(ArrayList.class); + assertThat(read.get("one").get(0)).isInstanceOf(LinkedHashSet.class).containsExactly("a", "b"); + } + + @Test + @DisplayName("A null Java map key is rejected while wrapping the stored map") + void converterFor_whenNullMapKey_throwsMapMustNotHaveNullKeys() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, String.class); + Map input = new HashMap<>(); + input.put(null, "value"); + AttributeConverter> converter = provider.converterFor(type); + + assertThatThrownBy(() -> converter.transformFrom(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Map must not have null keys."); + } + + @Test + @DisplayName("A null string map value is stored as DynamoDB NULL") + void converterFor_whenNullStringMapValue_roundTripsNullValue() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, String.class); + Map input = new LinkedHashMap<>(); + input.put("key", null); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m().get("key").nul()).isTrue(); + assertThat(read).isInstanceOf(LinkedHashMap.class); + assertThat(read).containsEntry("key", null); + } + + @Test + @DisplayName("SortedMap is currently unsupported") + void converterFor_whenSortedMap_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.sortedMapOf(String.class, Integer.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("NavigableMap is currently unsupported") + void converterFor_whenNavigableMap_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = + EnhancedType.navigableMapOf(String.class, Integer.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("ConcurrentMap is currently unsupported") + void converterFor_whenConcurrentMap_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = + EnhancedType.concurrentMapOf(String.class, Integer.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A named custom map implementation is currently unsupported") + void converterFor_whenCustomMapImplementation_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A nested list of Object fails converter lookup for the inner Object") + void converterFor_whenNestedListOfObject_throwsConverterNotFoundForInnerObject() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> listType = EnhancedType.listOf(Object.class); + EnhancedType>> type = + EnhancedType.mapOf(EnhancedType.of(String.class), listType); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("A nested list of an unsupported member names that list in the error") + void converterFor_whenNestedListOfUnsupportedType_throwsConverterNotFoundForList() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = + EnhancedType.mapOf(EnhancedType.of(String.class), EnhancedType.listOf(UnsupportedType.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.listOf(UnsupportedType.class)); + } + + @Test + @DisplayName("A raw nested Map value fails converter lookup") + void converterFor_whenRawNestedMapValue_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = + EnhancedType.mapOf(EnhancedType.of(String.class), EnhancedType.of(Map.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.Map). Type parameters are required for this type."); + } + + @Test + @DisplayName("A wildcard map value fails during nested raw-class validation") + void converterFor_whenWildcardValueMap_throwsWildcardNotExpected() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A wildcard type is not expected here."); + } + + @Test + @DisplayName("An integer map key is stored as the decimal string name") + void converterFor_whenIntegerKeyMap_roundTripsLinkedHashMap() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(Integer.class, String.class); + Map input = new LinkedHashMap<>(); + input.put(7, "value"); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m()).containsOnlyKeys("7"); + assertThat(read).isInstanceOf(LinkedHashMap.class).containsEntry(7, "value"); + } + + @Test + @DisplayName("A byte-array map key is stored as Base64 text") + void converterFor_whenByteArrayKeyMap_roundTripsBase64Key() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(byte[].class, String.class); + Map input = new LinkedHashMap<>(); + input.put(new byte[] {1, 2}, "value"); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Map read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(stored.m()).containsOnlyKeys("AQI="); + assertThat(read).isInstanceOf(LinkedHashMap.class).hasSize(1); + assertThat(read.keySet().iterator().next()).containsExactly((byte) 1, (byte) 2); + assertThat(read.values()).containsExactly("value"); + } + + @Test + @DisplayName("A captured LinkedHashMap type is currently unsupported") + void converterFor_whenLinkedHashMapType_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured TreeMap type is currently unsupported") + void converterFor_whenTreeMapType_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured ConcurrentHashMap type is currently unsupported") + void converterFor_whenConcurrentHashMapType_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = + new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured EnumMap type is unsupported before enum key conversion") + void converterFor_whenEnumMapType_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + static class UnsupportedType { + } + + private static TableSchema documentSchema() { + return StaticTableSchema.builder(DocumentType.class) + .newItemSupplier(DocumentType::new) + .addAttribute(String.class, a -> a.name("name") + .getter(DocumentType::getName) + .setter(DocumentType::setName)) + .build(); + } + + static class DocumentType { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + enum TestEnum { + OPEN, + CLOSED + } + + static class AttributeOnlyKey { + } + + static class CustomMap extends HashMap { + } + + static final class AttributeOnlyKeyConverter implements AttributeConverter { + @Override + public AttributeValue transformFrom(AttributeOnlyKey input) { + throw new UnsupportedOperationException(); + } + + @Override + public AttributeOnlyKey transformTo(AttributeValue input) { + throw new UnsupportedOperationException(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(AttributeOnlyKey.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderRegistryTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderRegistryTest.java new file mode 100644 index 000000000000..b13106d7efff --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderRegistryTest.java @@ -0,0 +1,395 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.PrimitiveConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DocumentAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests construction and configuration of default attribute converter providers. + *

+ * The tests compare the shared provider with independently constructed providers and providers created by the builder. + * They verify primitive aliases, duplicate registration precedence, exact type registrations, builder reuse and + * mutation, null converter validation, and behavior when no converters are registered. + */ +public class DefaultAttributeConverterProviderRegistryTest { + + @Test + @DisplayName("defaultProvider and create return the same singleton and cached string converter") + void create_whenDefaultProviderAlsoCalled_returnsSameSingletonAndCachedStringConverter() { + AttributeConverterProvider defaultProvider = AttributeConverterProvider.defaultProvider(); + DefaultAttributeConverterProvider created = DefaultAttributeConverterProvider.create(); + EnhancedType type = EnhancedType.of(String.class); + + AttributeConverter defaultConverter = defaultProvider.converterFor(type); + AttributeConverter createdConverter = created.converterFor(type); + + assertThat(defaultProvider).isSameAs(created); + assertThat(defaultConverter).isInstanceOf(StringAttributeConverter.class); + assertThat(createdConverter).isSameAs(defaultConverter); + } + + @Test + @DisplayName("Repeated create calls share the generated map converter cache") + void create_whenCalledTwice_sharesGeneratedMapConverterCache() { + DefaultAttributeConverterProvider firstProvider = DefaultAttributeConverterProvider.create(); + DefaultAttributeConverterProvider secondProvider = DefaultAttributeConverterProvider.create(); + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + + AttributeConverter> firstConverter = firstProvider.converterFor(type); + AttributeConverter> secondConverter = secondProvider.converterFor(type); + + assertThat(firstProvider).isSameAs(secondProvider); + assertThat(firstConverter).isInstanceOf(MapAttributeConverter.class); + assertThat(secondConverter).isSameAs(firstConverter); + } + + @Test + @DisplayName("The public constructor creates an isolated default registry") + void converterFor_whenPubliclyConstructedProvider_isIsolatedFromSingletonRegistry() { + DefaultAttributeConverterProvider singleton = DefaultAttributeConverterProvider.create(); + DefaultAttributeConverterProvider constructed = new DefaultAttributeConverterProvider(); + EnhancedType type = EnhancedType.of(String.class); + + AttributeConverter singletonConverter = singleton.converterFor(type); + AttributeConverter constructedConverter = constructed.converterFor(type); + + assertThat(singleton).isNotSameAs(constructed); + assertThat(singletonConverter).isInstanceOf(StringAttributeConverter.class); + assertThat(singletonConverter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(constructedConverter).isInstanceOf(StringAttributeConverter.class); + assertThat(constructedConverter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(constructedConverter).isNotSameAs(singletonConverter); + } + + @Test + @DisplayName("Publicly constructed providers own separate generated map caches") + void converterFor_whenTwoPubliclyConstructedProviders_ownSeparateGeneratedMapCaches() { + DefaultAttributeConverterProvider firstProvider = new DefaultAttributeConverterProvider(); + DefaultAttributeConverterProvider secondProvider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + + AttributeConverter> firstConverter = firstProvider.converterFor(type); + AttributeConverter> secondConverter = secondProvider.converterFor(type); + + assertThat(firstProvider).isNotSameAs(secondProvider); + assertThat(firstConverter).isInstanceOf(MapAttributeConverter.class); + assertThat(firstConverter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(secondConverter).isInstanceOf(MapAttributeConverter.class); + assertThat(secondConverter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(secondConverter).isNotSameAs(firstConverter); + } + + @Test + @DisplayName("An empty builder has no converter for String") + void converterFor_whenEmptyBuilder_throwsConverterNotFoundForString() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder().build(); + EnhancedType type = EnhancedType.of(String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.String)"); + } + + @Test + @DisplayName("A builder with a custom converter returns the supplied reference") + void converterFor_whenBuilderWithCustomConverter_returnsSuppliedReference() { + EnhancedType type = EnhancedType.of(CustomType.class); + TestAttributeConverter customConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(customConverter) + .build(); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isSameAs(customConverter); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + } + + @Test + @DisplayName("A primitive converter alias serves both int and Integer") + void converterFor_whenBuilderWithPrimitiveAlias_returnsSameConverterForIntAndInteger() { + TestPrimitiveConverter primitiveConverter = + new TestPrimitiveConverter<>(EnhancedType.of(Integer.class), EnhancedType.of(int.class)); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(primitiveConverter) + .build(); + + AttributeConverter integerConverter = provider.converterFor(EnhancedType.of(Integer.class)); + AttributeConverter intConverter = provider.converterFor(EnhancedType.of(int.class)); + + assertThat(intConverter).isSameAs(primitiveConverter); + assertThat(integerConverter).isSameAs(intConverter); + } + + @Test + @DisplayName("The first added converter wins for a duplicate type") + void converterFor_whenFirstAddedDuplicateRegistration_wins() { + EnhancedType type = EnhancedType.of(CustomType.class); + TestAttributeConverter firstConverter = new TestAttributeConverter<>(type); + TestAttributeConverter secondConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(firstConverter) + .addConverter(secondConverter) + .build(); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isSameAs(firstConverter); + assertThat(converter).isNotSameAs(secondConverter); + } + + @Test + @DisplayName("Adding a null converter is rejected") + void addConverter_whenNullConverter_throwsNullPointerException() { + DefaultAttributeConverterProvider.Builder builder = DefaultAttributeConverterProvider.builder(); + + assertThatThrownBy(() -> builder.addConverter(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("converter must not be null."); + } + + @Test + @DisplayName("A reused builder creates separate providers that share the supplied converter") + void build_whenReusedBuilder_createsSeparateProvidersSharingSuppliedConverter() { + EnhancedType type = EnhancedType.of(CustomType.class); + TestAttributeConverter customConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider.Builder builder = DefaultAttributeConverterProvider.builder() + .addConverter(customConverter); + + DefaultAttributeConverterProvider firstProvider = builder.build(); + DefaultAttributeConverterProvider secondProvider = builder.build(); + AttributeConverter firstConverter = firstProvider.converterFor(type); + AttributeConverter secondConverter = secondProvider.converterFor(type); + + assertThat(firstProvider).isNotSameAs(secondProvider); + assertThat(firstConverter).isSameAs(customConverter); + assertThat(secondConverter).isSameAs(customConverter); + } + + @Test + @DisplayName("An exact custom map registration precedes generated map selection") + void converterFor_whenExactCustomMapRegistration_precedesGeneratedMapSelection() { + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + TestAttributeConverter> customConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(customConverter) + .build(); + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isSameAs(customConverter); + assertThat(converter).isNotInstanceOf(MapAttributeConverter.class); + } + + @Test + @DisplayName("An exact custom set registration precedes generated set selection") + void converterFor_whenExactCustomSetRegistration_precedesGeneratedSetSelection() { + EnhancedType> type = EnhancedType.setOf(String.class); + TestAttributeConverter> customConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(customConverter) + .build(); + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isSameAs(customConverter); + assertThat(converter).isNotInstanceOf(SetAttributeConverter.class); + } + + @Test + @DisplayName("An exact custom list registration precedes generated list selection") + void converterFor_whenExactCustomListRegistration_precedesGeneratedListSelection() { + EnhancedType> type = EnhancedType.listOf(String.class); + TestAttributeConverter> customConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(customConverter) + .build(); + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isSameAs(customConverter); + assertThat(converter).isNotInstanceOf(ListAttributeConverter.class); + } + + @Test + @DisplayName("An exact custom enum registration precedes generated enum selection") + void converterFor_whenExactCustomEnumRegistration_precedesGeneratedEnumSelection() { + EnhancedType type = EnhancedType.of(TestEnum.class); + TestAttributeConverter customConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(customConverter) + .build(); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isSameAs(customConverter); + assertThat(converter).isNotInstanceOf(EnumAttributeConverter.class); + } + + @Test + @DisplayName("An exact custom document registration precedes generated document selection") + void converterFor_whenExactCustomDocumentRegistration_precedesGeneratedDocumentSelection() { + TableSchema schema = + StaticTableSchema.builder(DocumentType.class).newItemSupplier(DocumentType::new).build(); + EnhancedType type = EnhancedType.documentOf(DocumentType.class, schema); + TestAttributeConverter customConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(customConverter) + .build(); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isSameAs(customConverter); + assertThat(converter).isNotInstanceOf(DocumentAttributeConverter.class); + } + + @Test + @DisplayName("A built provider is isolated from later builder mutation") + void converterFor_whenBuilderMutatedAfterFirstBuild_throwsThenReturnsLaterConverter() { + TestAttributeConverter firstConverter = + new TestAttributeConverter<>(EnhancedType.of(CustomType.class)); + DefaultAttributeConverterProvider.Builder builder = DefaultAttributeConverterProvider.builder() + .addConverter(firstConverter); + DefaultAttributeConverterProvider firstProvider = builder.build(); + EnhancedType secondType = EnhancedType.of(SecondCustomType.class); + TestAttributeConverter secondConverter = new TestAttributeConverter<>(secondType); + builder.addConverter(secondConverter); + DefaultAttributeConverterProvider secondProvider = builder.build(); + + assertThatThrownBy(() -> firstProvider.converterFor(secondType)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + secondType); + assertThat(secondProvider.converterFor(secondType)).isSameAs(secondConverter); + } + + @Test + @DisplayName("An exact custom registration intercepts a schema-bearing Object type") + void converterFor_whenExactCustomRegistration_interceptsSchemaBearingObject() { + TableSchema objectSchema = + StaticTableSchema.builder(Object.class).newItemSupplier(Object::new).build(); + EnhancedType type = EnhancedType.documentOf(Object.class, objectSchema); + TestAttributeConverter customConverter = new TestAttributeConverter<>(type); + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.builder() + .addConverter(customConverter) + .build(); + + AttributeConverter converter = provider.converterFor(type); + + assertThat(converter).isSameAs(customConverter); + assertThat(converter).isNotInstanceOf(MapAttributeConverter.class); + } + + private static final class TestAttributeConverter implements AttributeConverter { + private final EnhancedType type; + + TestAttributeConverter(EnhancedType type) { + this.type = type; + } + + @Override + public AttributeValue transformFrom(T input) { + throw new UnsupportedOperationException(); + } + + @Override + public T transformTo(AttributeValue input) { + throw new UnsupportedOperationException(); + } + + @Override + public EnhancedType type() { + return type; + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + private static final class TestPrimitiveConverter implements AttributeConverter, PrimitiveConverter { + private final EnhancedType type; + private final EnhancedType primitiveType; + + TestPrimitiveConverter(EnhancedType type, EnhancedType primitiveType) { + this.type = type; + this.primitiveType = primitiveType; + } + + @Override + public AttributeValue transformFrom(T input) { + throw new UnsupportedOperationException(); + } + + @Override + public T transformTo(AttributeValue input) { + throw new UnsupportedOperationException(); + } + + @Override + public EnhancedType type() { + return type; + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + + @Override + public EnhancedType primitiveType() { + return primitiveType; + } + } + + static class DocumentType { + } + + enum TestEnum { + OPEN, + CLOSED + } + + static class CustomType { + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + static class SecondCustomType { + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderSetTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderSetTest.java new file mode 100644 index 000000000000..5d9b81e433ad --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderSetTest.java @@ -0,0 +1,482 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.NavigableSet; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.CopyOnWriteArraySet; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests generated converters for set types and compatible collection declarations. + *

+ * The tests verify string, number, binary, and enumeration sets together with ordering, empty values, and set member + * validation. They also cover collection and iterable inputs, recursive lookup failures, and unsupported concrete or + * parameterized set declarations. + */ +public class DefaultAttributeConverterProviderSetTest { + + private static final String UNSUPPORTED_SET_MEMBER_SUFFIX = + "'. Supported parameterized types must convert to B, S or N DynamoDB AttributeValues."; + + @Test + @DisplayName("String set conversion preserves insertion order as SS and reads a LinkedHashSet") + void converterFor_whenStringSet_convertsToInsertionOrderedSsAndReadsLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(String.class); + Set input = linkedSet("a", "b"); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Set read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(stored.ss()).containsExactly("a", "b"); + assertThat(read).isInstanceOf(LinkedHashSet.class) + .isEqualTo(linkedSet("a", "b")) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("Number set conversion preserves insertion order as NS and reads a LinkedHashSet") + void converterFor_whenIntegerSet_convertsToInsertionOrderedNsAndReadsLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(Integer.class); + Set input = linkedSet(1, 2); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Set read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.NS); + assertThat(stored.ns()).containsExactly("1", "2"); + assertThat(read).isInstanceOf(LinkedHashSet.class) + .isEqualTo(linkedSet(1, 2)) + .containsExactly(1, 2); + } + + @Test + @DisplayName("Binary set conversion stores BS and reads a LinkedHashSet") + void converterFor_whenSdkBytesSet_convertsToBsAndReadsLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(SdkBytes.class); + SdkBytes bytes = SdkBytes.fromUtf8String("a"); + Set input = linkedSet(bytes); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Set read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.BS); + assertThat(stored.bs()).containsExactly(SdkBytes.fromUtf8String("a")); + assertThat(read).isInstanceOf(LinkedHashSet.class) + .isEqualTo(linkedSet(bytes)) + .containsExactly(bytes); + } + + @Test + @DisplayName("Enum set conversion stores toString() member text as SS") + void converterFor_whenEnumSet_convertsToSsUsingToStringAndReadsLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(TestEnum.class); + Set input = linkedSet(TestEnum.OPEN); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Set read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(stored.ss()).containsExactly(TestEnum.OPEN.toString()); + assertThat(read).isInstanceOf(LinkedHashSet.class) + .containsExactly(TestEnum.OPEN); + } + + @Test + @DisplayName("Empty set conversion stores empty SS and reads an empty LinkedHashSet") + void converterFor_whenEmptyStringSet_convertsToEmptySsAndReadsEmptyLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(String.class); + Set input = new LinkedHashSet<>(); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Set read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(stored.ss()).isEmpty(); + assertThat(read).isInstanceOf(LinkedHashSet.class) + .isEmpty(); + } + + @Test + @DisplayName("A null string set member produces a non-string attribute and is rejected") + void converterFor_whenStringSetWithNullMember_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(String.class); + Set input = new LinkedHashSet<>(); + input.add(null); + + assertThatThrownBy(() -> provider.converterFor(type).transformFrom(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute value must be S."); + } + + @Test + @DisplayName("A list member converter declaring L is rejected as a set member") + void converterFor_whenListMemberSet_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = EnhancedType.setOf(EnhancedType.listOf(String.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(unsupportedSetMemberMessage(List.class)); + } + + @Test + @DisplayName("A map member converter declaring M is rejected as a set member") + void converterFor_whenMapMemberSet_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType>> type = + EnhancedType.setOf(EnhancedType.mapOf(String.class, String.class)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(unsupportedSetMemberMessage(Map.class)); + } + + @Test + @DisplayName("A document member converter declaring M is rejected as a set member") + void converterFor_whenDocumentMemberSet_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + TableSchema documentSchema = + StaticTableSchema.builder(DocumentType.class).newItemSupplier(DocumentType::new).build(); + EnhancedType> type = + EnhancedType.setOf(EnhancedType.documentOf(DocumentType.class, documentSchema)); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(unsupportedSetMemberMessage(DocumentType.class)); + } + + @Test + @DisplayName("A custom NULL member converter is rejected as a set member") + void converterFor_whenNullAttributeTypeMemberSet_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = providerWithDeclaredMemberConverter( + NullSetMember.class, AttributeValueType.NULL); + EnhancedType> type = EnhancedType.setOf(NullSetMember.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(unsupportedSetMemberMessage(NullSetMember.class)); + } + + @Test + @DisplayName("A custom SS member converter is rejected as a set member") + void converterFor_whenSsAttributeTypeMemberSet_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = providerWithDeclaredMemberConverter( + StringSetMember.class, AttributeValueType.SS); + EnhancedType> type = EnhancedType.setOf(StringSetMember.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(unsupportedSetMemberMessage(StringSetMember.class)); + } + + @Test + @DisplayName("A custom NS member converter is rejected as a set member") + void converterFor_whenNsAttributeTypeMemberSet_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = providerWithDeclaredMemberConverter( + NumberSetMember.class, AttributeValueType.NS); + EnhancedType> type = EnhancedType.setOf(NumberSetMember.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(unsupportedSetMemberMessage(NumberSetMember.class)); + } + + @Test + @DisplayName("A custom BS member converter is rejected as a set member") + void converterFor_whenBsAttributeTypeMemberSet_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = providerWithDeclaredMemberConverter( + BinarySetMember.class, AttributeValueType.BS); + EnhancedType> type = EnhancedType.setOf(BinarySetMember.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(unsupportedSetMemberMessage(BinarySetMember.class)); + } + + @Test + @DisplayName("An Object set member fails converter lookup") + void converterFor_whenObjectMemberSet_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(Object.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("A document class without a schema is an unsupported set member") + void converterFor_whenDocumentClassWithoutSchemaSet_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.setOf(DocumentType.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A wildcard set member fails while reading the raw class") + void converterFor_whenWildcardSetMember_throwsIllegalArgumentException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("A wildcard type is not expected here."); + } + + @Test + @DisplayName("Collection of String uses set semantics and reads a LinkedHashSet") + void converterFor_whenStringCollection_convertsToSsAndReadsLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.collectionOf(String.class); + Collection input = new ArrayList<>(); + input.add("a"); + input.add("b"); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Collection read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(stored.ss()).containsExactly("a", "b"); + assertThat(read).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("Iterable of String uses set semantics when the value is a LinkedHashSet") + void converterFor_whenStringIterableWithLinkedHashSet_convertsToSsAndReadsLinkedHashSet() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + Iterable input = linkedSet("a", "b"); + + AttributeConverter> converter = provider.converterFor(type); + AttributeValue stored = converter.transformFrom(input); + Iterable read = converter.transformTo(stored); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(stored.ss()).containsExactly("a", "b"); + assertThat(read).isInstanceOf(LinkedHashSet.class) + .isEqualTo(linkedSet("a", "b")); + } + + @Test + @DisplayName("A non-Collection Iterable is selected as a set converter and fails while converting") + void converterFor_whenNonCollectionIterable_throwsClassCastException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + Iterable input = new NonCollectionIterable(); + + AttributeConverter> converter = provider.converterFor(type); + + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThatThrownBy(() -> converter.transformFrom(input)) + .isInstanceOf(ClassCastException.class); + } + + @Test + @DisplayName("SortedSet is currently unsupported") + void converterFor_whenSortedSetOfString_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.sortedSetOf(String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("NavigableSet is currently unsupported") + void converterFor_whenNavigableSetOfString_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = EnhancedType.navigableSetOf(String.class); + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A named custom set implementation is currently unsupported") + void converterFor_whenCustomSet_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured LinkedHashSet is currently unsupported") + void converterFor_whenLinkedHashSet_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured TreeSet is currently unsupported") + void converterFor_whenTreeSet_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A captured CopyOnWriteArraySet is currently unsupported") + void converterFor_whenCopyOnWriteArraySet_throwsIllegalStateException() { + DefaultAttributeConverterProvider provider = new DefaultAttributeConverterProvider(); + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + private static DefaultAttributeConverterProvider providerWithDeclaredMemberConverter( + Class memberClass, AttributeValueType attributeValueType) { + return DefaultAttributeConverterProvider.builder() + .addConverter(new DeclaredTypeConverter<>(memberClass, attributeValueType)) + .build(); + } + + private static String unsupportedSetMemberMessage(Class rawClass) { + return "SetAttributeConverter cannot be created with a parameterized type of '" + rawClass + + UNSUPPORTED_SET_MEMBER_SUFFIX; + } + + @SafeVarargs + private static LinkedHashSet linkedSet(T... values) { + LinkedHashSet result = new LinkedHashSet<>(); + result.addAll(Arrays.asList(values)); + return result; + } + + private static final class DeclaredTypeConverter implements AttributeConverter { + private final EnhancedType type; + private final AttributeValueType attributeValueType; + + DeclaredTypeConverter(Class rawClass, AttributeValueType attributeValueType) { + this.type = EnhancedType.of(rawClass); + this.attributeValueType = attributeValueType; + } + + @Override + public AttributeValue transformFrom(T input) { + throw new UnsupportedOperationException(); + } + + @Override + public T transformTo(AttributeValue input) { + throw new UnsupportedOperationException(); + } + + @Override + public EnhancedType type() { + return type; + } + + @Override + public AttributeValueType attributeValueType() { + return attributeValueType; + } + } + + static class DocumentType { + } + + static class NullSetMember { + } + + static class StringSetMember { + } + + static class NumberSetMember { + } + + static class BinarySetMember { + } + + enum TestEnum { + OPEN, + CLOSED + } + + static final class CustomSet extends HashSet { + } + + static final class NonCollectionIterable implements Iterable { + @Override + public Iterator iterator() { + return Collections.singletonList("a").iterator(); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderTest.java index 417c8fb24547..896ae1feda1b 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DefaultAttributeConverterProviderTest.java @@ -18,10 +18,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import org.apache.logging.log4j.core.LogEvent; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.slf4j.event.Level; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; public class DefaultAttributeConverterProviderTest { @@ -47,8 +52,8 @@ void findConverter_whenConverterNotFound_logsNoConverter() { assertThatThrownBy(() -> provider.converterFor(EnhancedType.of(CustomUnsupportedType.class))) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Converter not found for EnhancedType(software.amazon.awssdk.enhanced.dynamodb" - + ".DefaultAttributeConverterProviderTest$CustomUnsupportedType)"); + .hasMessage("Converter not found for EnhancedType(software.amazon.awssdk.enhanced.dynamodb" + + ".DefaultAttributeConverterProviderTest$CustomUnsupportedType)"); List logEvents = logCaptor.loggedEvents(); assertThat(logEvents).hasSize(1); assertThat(logEvents.get(0).getLevel().name()).isEqualTo(Level.DEBUG.name()); @@ -58,9 +63,134 @@ void findConverter_whenConverterNotFound_logsNoConverter() { } } + @Test + void findConverter_whenConverterIsCached_returnsTheCachedConverter() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + assertThat(provider.converterFor(EnhancedType.of(String.class))) + .isSameAs(provider.converterFor(EnhancedType.of(String.class))); + } + + @Test + void findConverter_whenMapSubtypeHasSupportedEntries_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + EnhancedType> type = new EnhancedType>() { }; + + assertThatThrownBy(() -> provider.converterFor(type)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("An Object map value fails converter lookup") + void findConverter_whenMapEntryValueIsObject_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.mapOf(String.class, Object.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + void findConverter_whenMapEntryValueHasNoConverter_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.mapOf(String.class, CustomUnsupportedType.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.Map)"); + } + + @Test + void findConverter_whenSetHasSupportedEntries_createsSetConverter() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + assertThat(provider.converterFor(EnhancedType.setOf(String.class)) + .transformFrom(Collections.singleton("value")).ss()) + .containsExactly("value"); + } + + @Test + void findConverter_whenSetEntryHasNoConverter_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.setOf(CustomUnsupportedType.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.Set<" + + "software.amazon.awssdk.enhanced.dynamodb.DefaultAttributeConverterProviderTest" + + "$CustomUnsupportedType>)"); + } + + @Test + void findConverter_whenListHasSupportedEntries_createsListConverter() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + assertThat(provider.converterFor(EnhancedType.listOf(String.class)) + .transformFrom(Collections.singletonList("value")).l()) + .containsExactly(AttributeValue.builder().s("value").build()); + } + + @Test + void findConverter_whenListEntryHasNoConverter_throwsConverterNotFound() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + assertThatThrownBy(() -> provider.converterFor(EnhancedType.listOf(CustomUnsupportedType.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.util.List<" + + "software.amazon.awssdk.enhanced.dynamodb.DefaultAttributeConverterProviderTest" + + "$CustomUnsupportedType>)"); + } + + @Test + void findConverter_whenTypeIsEnum_createsEnumConverter() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + + assertThat(provider.converterFor(EnhancedType.of(TestEnum.class)).transformFrom(TestEnum.VALUE).s()) + .isEqualTo("VALUE"); + } + + @Test + void findConverter_whenTypeHasTableSchema_createsDocumentConverter() { + DefaultAttributeConverterProvider provider = DefaultAttributeConverterProvider.create(); + TableSchema schema = StaticTableSchema.builder(TestDocument.class) + .newItemSupplier(TestDocument::new) + .addAttribute(String.class, a -> a.name("value") + .getter(TestDocument::value) + .setter(TestDocument::value)) + .build(); + + assertThat(provider.converterFor(EnhancedType.documentOf(TestDocument.class, schema)) + .transformFrom(new TestDocument("value")).m()) + .containsEntry("value", AttributeValue.builder().s("value").build()); + } + /** * A custom type with no converter registered for it. */ private static class CustomUnsupportedType { } + + private enum TestEnum { + VALUE + } + + private static final class TestDocument { + private String value; + + private TestDocument() { + } + + private TestDocument(String value) { + this.value = value; + } + + private String value() { + return value; + } + + private void value(String value) { + this.value = value; + } + } } diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DocumentTableSchemaConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DocumentTableSchemaConverterTest.java new file mode 100644 index 000000000000..4abbe183be15 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/DocumentTableSchemaConverterTest.java @@ -0,0 +1,459 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.document.DocumentTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests typed item conversion through document table schemas. + *

+ * The tests verify default and configured converter providers, provider ordering, fallback, and propagated failures. + * They also cover typed scalar, list, and nested document values, explicit document nulls, null items, preconverted + * attribute values, schema metadata, and unsupported attribute converter lookup. + */ +public class DocumentTableSchemaConverterTest { + + @Test + @DisplayName("The default schema provider supports typed reads") + void mapToItem_whenDefaultSchema_installsDefaultProviderAndReadsString() { + EnhancedType type = EnhancedType.of(String.class); + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + Map map = Collections.singletonMap("value", AttributeValue.fromS("text")); + + EnhancedDocument document = schema.mapToItem(map); + String read = document.get("value", type); + + assertThat(document.attributeConverterProviders()) + .contains(AttributeConverterProvider.defaultProvider()); + assertThat(read).isEqualTo("text"); + } + + @Test + @DisplayName("The default schema provider supports typed writes") + void itemToMap_whenTypedIntegerDocument_storesN() { + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", 7, EnhancedType.of(Integer.class)) + .build(); + + Map map = schema.itemToMap(document, false); + + assertThat(map).containsEntry("value", AttributeValue.fromN("7")); + } + + @Test + @DisplayName("A document provider precedes the schema provider and is invoked twice by the merged chain") + void itemToMap_whenDocumentProviderBeforeSchemaProvider_selectsDocumentConverterAndCallsItTwice() { + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingPrefixProvider documentProvider = new RecordingPrefixProvider("document"); + RecordingPrefixProvider schemaProvider = new RecordingPrefixProvider("schema"); + EnhancedDocument document = EnhancedDocument.builder() + .attributeConverterProviders(documentProvider) + .put("value", new CustomType("x"), type) + .build(); + DocumentTableSchema schema = DocumentTableSchema.builder() + .attributeConverterProviders(schemaProvider) + .build(); + + Map map = schema.itemToMap(document, false); + + assertThat(documentProvider.requestedTypes()).containsExactly(type, type); + assertThat(map.get("value").s()).isEqualTo("document:x"); + assertThat(schemaProvider.requestedTypes()).isEmpty(); + } + + @Test + @DisplayName("A null-returning document provider falls back to the schema default") + void itemToMap_whenNullReturningDocumentProvider_fallsBackToSchemaDefault() { + EnhancedType type = EnhancedType.of(String.class); + ReturningNullProvider returningNull = new ReturningNullProvider(); + EnhancedDocument document = EnhancedDocument.builder() + .attributeConverterProviders(returningNull) + .put("value", "text", type) + .build(); + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + + Map map = schema.itemToMap(document, false); + + assertThat(returningNull.requestedTypes()).containsExactly(type); + assertThat(map).containsEntry("value", AttributeValue.fromS("text")); + } + + @Test + @DisplayName("A default document provider can block a later schema custom provider") + void itemToMap_whenDefaultDocumentProviderBeforeSchemaCustom_throwsConverterNotFoundAndSkipsSchemaCustom() { + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingPrefixProvider schemaCustom = new RecordingPrefixProvider("schema"); + EnhancedDocument document = EnhancedDocument.builder() + .attributeConverterProviders( + AttributeConverterProvider.defaultProvider()) + .put("value", new CustomType("x"), type) + .build(); + DocumentTableSchema schema = DocumentTableSchema.builder() + .attributeConverterProviders(schemaCustom) + .build(); + + assertThatThrownBy(() -> schema.itemToMap(document, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(schemaCustom.requestedTypes()).isEmpty(); + } + + @Test + @DisplayName("Empty schema providers fail lazily for typed data") + void itemToMap_whenEmptySchemaProvidersAndTypedString_throwsDocumentConverterNotFound() { + EnhancedType type = EnhancedType.of(String.class); + DocumentTableSchema schema = DocumentTableSchema.builder() + .attributeConverterProviders(Collections.emptyList()) + .build(); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", "text", type) + .build(); + + assertThatThrownBy(() -> schema.itemToMap(document, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessage(documentConverterNotFoundMessage(type)); + } + + @Test + @DisplayName("Empty schema providers preserve preconverted AttributeValue data") + void itemToMap_whenEmptySchemaProvidersAndAttributeValue_preservesSWithoutConverterLookup() { + DocumentTableSchema schema = DocumentTableSchema.builder() + .attributeConverterProviders(Collections.emptyList()) + .build(); + EnhancedDocument document = EnhancedDocument.builder() + .attributeValueMap(Collections.singletonMap( + "value", AttributeValue.fromS("text"))) + .build(); + + Map map = schema.itemToMap(document, false); + + assertThat(map).containsEntry("value", AttributeValue.fromS("text")); + } + + @Test + @DisplayName("A null item write is explicitly supported") + void itemToMap_whenNullItem_returnsNull() { + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + + assertThat(schema.itemToMap(null, false)).isNull(); + } + + @Test + @DisplayName("A null map read is explicitly supported") + void mapToItem_whenNullMap_returnsNull() { + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + + assertThat(schema.mapToItem(null)).isNull(); + } + + @Test + @DisplayName("A false ignore-null flag retains explicit document null") + void itemToMap_whenExplicitNullWithIgnoreNullsFalse_retainsNul() { + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + EnhancedDocument document = EnhancedDocument.builder().putNull("value").build(); + + Map map = schema.itemToMap(document, false); + + assertThat(map).containsEntry("value", AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("A true ignore-null flag also retains explicit document null") + void itemToMap_whenExplicitNullWithIgnoreNullsTrue_retainsNul() { + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + EnhancedDocument document = EnhancedDocument.builder().putNull("value").build(); + + Map map = schema.itemToMap(document, true); + + assertThat(map).containsEntry("value", AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("Attribute converter lookup is unsupported") + void converterForAttribute_whenAnyName_throwsUnsupportedOperationExceptionWithNullMessage() { + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + + assertThatThrownBy(() -> schema.converterForAttribute("value")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage(null); + } + + @Test + @DisplayName("The document schema item type is fixed as EnhancedDocument") + void itemType_whenBuiltSchema_returnsEnhancedDocumentType() { + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + + assertThat(schema.itemType()).isEqualTo(EnhancedType.of(EnhancedDocument.class)); + } + + @Test + @DisplayName("A typed Object write fails converter lookup") + void itemToMap_whenTypedObject_throwsConverterNotFound() { + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", new Object(), EnhancedType.of(Object.class)) + .build(); + + assertThatThrownBy(() -> schema.itemToMap(document, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.Object)"); + } + + @Test + @DisplayName("An unsupported typed write propagates the schema default provider failure") + void itemToMap_whenUnsupportedType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", new UnsupportedType("x"), type) + .build(); + + assertThatThrownBy(() -> schema.itemToMap(document, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("The default schema provider converts a typed list") + void itemToMapThenMapToItem_whenTypedStringList_roundTripsAsLAndArrayList() { + EnhancedType> type = EnhancedType.listOf(String.class); + List input = new ArrayList<>(); + input.add("a"); + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", input, type) + .build(); + + Map map = schema.itemToMap(document, false); + EnhancedDocument readDocument = schema.mapToItem(map); + List read = readDocument.get("value", type); + + assertThat(map.get("value").hasL()).isTrue(); + assertThat(map.get("value").l()).containsExactly(AttributeValue.fromS("a")); + assertThat(read).isInstanceOf(ArrayList.class).isEqualTo(input); + } + + @Test + @DisplayName("The default schema provider converts a typed nested document") + void itemToMapThenMapToItem_whenSchemaBearingDocument_storesMAndReconstructsThroughSchema() { + TableSchema documentSchema = documentSchema(); + EnhancedType type = EnhancedType.documentOf(DocumentType.class, documentSchema); + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + DocumentType input = new DocumentType(); + input.setName("doc"); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", input, type) + .build(); + + Map map = schema.itemToMap(document, false); + EnhancedDocument readDocument = schema.mapToItem(map); + DocumentType read = readDocument.get("value", type); + + assertThat(map.get("value").hasM()).isTrue(); + assertThat(map.get("value").m()).containsEntry("name", AttributeValue.fromS("doc")); + assertThat(read.getName()).isEqualTo("doc"); + } + + @Test + @DisplayName("The default schema provider rejects a typed concrete list") + void itemToMap_whenConcreteArrayList_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { }; + ArrayList input = new ArrayList<>(); + input.add("a"); + DocumentTableSchema schema = DocumentTableSchema.builder().build(); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", input, type) + .build(); + + assertThatThrownBy(() -> schema.itemToMap(document, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A schema provider exception propagates through document conversion") + void itemToMap_whenThrowingSchemaProvider_propagatesExceptionAndSkipsLaterProvider() { + EnhancedType type = EnhancedType.of(CustomType.class); + AttributeConverterProvider throwing = new ThrowingProvider(); + AttributeConverterProvider later = mock(AttributeConverterProvider.class); + DocumentTableSchema schema = DocumentTableSchema.builder() + .attributeConverterProviders(throwing, later) + .build(); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", new CustomType("x"), type) + .build(); + + assertThatThrownBy(() -> schema.itemToMap(document, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute converter provider failed while looking up " + type); + verifyNoInteractions(later); + } + + @Test + @DisplayName("Object validation fails before a merged schema provider is invoked") + void itemToMap_whenObjectTypeWithObjectProviderOnSchema_throwsConverterNotFoundWithoutInvokingProvider() { + AttributeConverterProvider objectProvider = mock(AttributeConverterProvider.class); + DocumentTableSchema schema = DocumentTableSchema.builder() + .attributeConverterProviders( + objectProvider, + AttributeConverterProvider.defaultProvider()) + .build(); + EnhancedDocument document = EnhancedDocument.builder() + .put("value", new Object(), EnhancedType.of(Object.class)) + .build(); + + assertThatThrownBy(() -> schema.itemToMap(document, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.Object)"); + verifyNoInteractions(objectProvider); + } + + private static TableSchema documentSchema() { + return StaticTableSchema.builder(DocumentType.class) + .newItemSupplier(DocumentType::new) + .addAttribute(String.class, a -> a.name("name") + .getter(DocumentType::getName) + .setter(DocumentType::setName)) + .build(); + } + + private static String documentConverterNotFoundMessage(EnhancedType type) { + return "AttributeConverter not found for class " + type + + ". Please add an AttributeConverterProvider for this type. If it is a default type, add the " + + "DefaultAttributeConverterProvider to the builder."; + } + + static final class CustomType { + private final String value; + + CustomType(String value) { + this.value = value; + } + + String value() { + return value; + } + } + + static final class UnsupportedType { + private final String value; + + UnsupportedType(String value) { + this.value = value; + } + } + + static final class DocumentType { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + static final class ReturningNullProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return null; + } + + List> requestedTypes() { + return requestedTypes; + } + } + + static final class CustomTypeConverter implements AttributeConverter { + private final String prefix; + + CustomTypeConverter(String prefix) { + this.prefix = prefix; + } + + @Override + public AttributeValue transformFrom(CustomType input) { + return AttributeValue.fromS(prefix + ":" + input.value()); + } + + @Override + public CustomType transformTo(AttributeValue input) { + String text = input.s(); + return new CustomType(text.substring(prefix.length() + 1)); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(CustomType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + static final class RecordingPrefixProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + private final CustomTypeConverter converter; + + RecordingPrefixProvider(String prefix) { + this.converter = new CustomTypeConverter(prefix); + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(CustomType.class).equals(enhancedType)) { + return (AttributeConverter) converter; + } + return null; + } + + List> requestedTypes() { + return requestedTypes; + } + } + + static final class ThrowingProvider implements AttributeConverterProvider { + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + throw new IllegalArgumentException("Attribute converter provider failed while looking up " + enhancedType); + } + } + +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/EnhancedDocumentConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/EnhancedDocumentConverterTest.java new file mode 100644 index 000000000000..451c0c540667 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/EnhancedDocumentConverterTest.java @@ -0,0 +1,880 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests typed value conversion performed by enhanced documents. + *

+ * The tests cover scalar, enumeration, collection, map, iterable, and schema backed document values. They verify + * provider ordering and fallback, recursive member lookup, JSON provider configuration, explicit null values, + * unsupported type errors, and propagation of converter failures. + */ +public class EnhancedDocumentConverterTest { + + @Test + @DisplayName("Typed scalar conversion stores S text and reads the original string") + void putThenGet_whenStringType_storesSTextAndReadsOriginalString() { + EnhancedType type = EnhancedType.of(String.class); + + EnhancedDocument document = defaultBuilder().put("value", "text", type).build(); + Map map = document.toMap(); + String read = document.get("value", type); + + assertThat(map.get("value")).isEqualTo(AttributeValue.fromS("text")); + assertThat(read).isEqualTo("text"); + } + + @Test + @DisplayName("Typed list conversion stores L and reads an ArrayList that retains order and duplicates") + void putThenGet_whenStringList_storesLAndReadsArrayListRetainingOrderAndDuplicates() { + EnhancedType> type = EnhancedType.listOf(String.class); + List input = new ArrayList<>(); + input.add("a"); + input.add("b"); + input.add("a"); + + EnhancedDocument document = defaultBuilder().put("value", input, type).build(); + Map map = document.toMap(); + List read = document.get("value", type); + + assertThat(map.get("value").hasL()).isTrue(); + assertThat(read).isInstanceOf(ArrayList.class) + .containsExactly("a", "b", "a") + .isEqualTo(input); + } + + @Test + @DisplayName("Typed set conversion stores SS and reads an equal LinkedHashSet") + void putThenGet_whenStringSet_storesSsAndReadsEqualLinkedHashSet() { + EnhancedType> type = EnhancedType.setOf(String.class); + Set input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + + EnhancedDocument document = defaultBuilder().put("value", input, type).build(); + Map map = document.toMap(); + Set read = document.get("value", type); + + assertThat(map.get("value").hasSs()).isTrue(); + assertThat(read).isInstanceOf(LinkedHashSet.class).isEqualTo(input); + } + + @Test + @DisplayName("Typed map conversion stores M with an N member and reads an equal LinkedHashMap") + void putThenGet_whenStringToIntegerMap_storesMWithNMemberAndReadsEqualLinkedHashMap() { + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + Map input = new LinkedHashMap<>(); + input.put("count", 7); + + EnhancedDocument document = defaultBuilder().put("value", input, type).build(); + Map map = document.toMap(); + Map read = document.get("value", type); + + assertThat(map.get("value").hasM()).isTrue(); + assertThat(map.get("value").m().get("count")).isEqualTo(AttributeValue.fromN("7")); + assertThat(read).isInstanceOf(LinkedHashMap.class).isEqualTo(input); + } + + @Test + @DisplayName("Typed enum conversion stores toString text and reads the same constant") + void putThenGet_whenEnumType_storesToStringTextAndReadsSameConstant() { + EnhancedType type = EnhancedType.of(TestEnum.class); + TestEnum input = TestEnum.OPEN; + + EnhancedDocument document = defaultBuilder().put("value", input, type).build(); + Map map = document.toMap(); + TestEnum read = document.get("value", type); + + assertThat(map.get("value")).isEqualTo(AttributeValue.fromS(input.toString())); + assertThat(read).isSameAs(input); + } + + @Test + @DisplayName("Typed document conversion stores M and reconstructs through the table schema") + void putThenGet_whenSchemaBearingDocument_storesMAndReconstructsThroughTableSchema() { + TableSchema documentSchema = documentSchema(); + EnhancedType type = EnhancedType.documentOf(DocumentType.class, documentSchema); + DocumentType input = new DocumentType(); + input.setName("doc"); + + EnhancedDocument document = defaultBuilder().put("value", input, type).build(); + Map map = document.toMap(); + DocumentType read = document.get("value", type); + + assertThat(map.get("value").hasM()).isTrue(); + assertThat(map.get("value").m()).containsEntry("name", AttributeValue.fromS("doc")); + assertThat(read.getName()).isEqualTo("doc"); + } + + @Test + @DisplayName("Deep typed document conversion nests L then M then M and reconstructs the document") + void putThenGet_whenListOfMapOfDocument_nestsLThenMThenMAndReconstructsDocument() { + TableSchema documentSchema = documentSchema(); + EnhancedType>> type = + EnhancedType.listOf(EnhancedType.mapOf(EnhancedType.of(String.class), + EnhancedType.documentOf(DocumentType.class, documentSchema))); + DocumentType nested = new DocumentType(); + nested.setName("doc"); + Map inner = new LinkedHashMap<>(); + inner.put("doc", nested); + List> input = new ArrayList<>(); + input.add(inner); + + EnhancedDocument document = defaultBuilder().put("value", input, type).build(); + Map map = document.toMap(); + List> read = document.get("value", type); + + assertThat(map.get("value").hasL()).isTrue(); + assertThat(map.get("value").l().get(0).hasM()).isTrue(); + assertThat(map.get("value").l().get(0).m().get("doc").hasM()).isTrue(); + assertThat(read).isInstanceOf(ArrayList.class); + assertThat(read.get(0)).isInstanceOf(LinkedHashMap.class); + assertThat(read.get(0).get("doc").getName()).isEqualTo("doc"); + } + + @Test + @DisplayName("A builder with no provider succeeds on build then fails on toMap with the document error") + void toMap_whenBuilderWithNoProvider_throwsDocumentConverterNotFound() { + EnhancedType type = EnhancedType.of(String.class); + + EnhancedDocument document = EnhancedDocument.builder().put("value", "text", type).build(); + + assertThat(document).isNotNull(); + assertThatThrownBy(document::toMap) + .isInstanceOf(IllegalStateException.class) + .hasMessage(documentConverterNotFoundMessage(type)); + } + + @Test + @DisplayName("An all-null custom chain reports the document error and calls each provider once") + void toMap_whenAllNullCustomChain_throwsDocumentConverterNotFoundAndCallsEachProviderOnce() { + EnhancedType type = EnhancedType.of(String.class); + ReturningNullProvider first = new ReturningNullProvider(); + ReturningNullProvider second = new ReturningNullProvider(); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders(first, second) + .put("value", "text", type) + .build() + .toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage(documentConverterNotFoundMessage(type)); + assertThat(first.requestedTypes()).containsExactly(type); + assertThat(second.requestedTypes()).containsExactly(type); + } + + @Test + @DisplayName("A null-returning custom provider falls back to the default provider") + void toMap_whenNullReturningCustomThenDefault_fallsBackToDefaultStringConverter() { + EnhancedType type = EnhancedType.of(String.class); + ReturningNullProvider returningNull = new ReturningNullProvider(); + + Map map = EnhancedDocument.builder() + .attributeConverterProviders( + returningNull, + AttributeConverterProvider.defaultProvider()) + .put("value", "text", type) + .build() + .toMap(); + + assertThat(returningNull.requestedTypes()).containsExactly(type); + assertThat(map.get("value")).isEqualTo(AttributeValue.fromS("text")); + } + + @Test + @DisplayName("A custom provider before the default is selected twice by the chain") + void toMap_whenCustomProviderBeforeDefault_selectsCustomConverterAndCallsProviderTwice() { + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingCustomProvider customProvider = new RecordingCustomProvider(); + + Map map = EnhancedDocument.builder() + .attributeConverterProviders( + customProvider, + AttributeConverterProvider.defaultProvider()) + .put("value", new CustomType("x"), type) + .build() + .toMap(); + + assertThat(customProvider.requestedTypes()).containsExactly(type, type); + assertThat(customProvider.converter().attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("custom:x"); + } + + @Test + @DisplayName("A throwing custom provider exception propagates and skips the default provider") + void toMap_whenThrowingCustomProviderBeforeDefault_propagatesExceptionAndSkipsDefault() { + EnhancedType type = EnhancedType.of(CustomType.class); + AttributeConverterProvider throwing = new ThrowingProvider(); + AttributeConverterProvider defaultProvider = mock(AttributeConverterProvider.class); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders(throwing, defaultProvider) + .put("value", new CustomType("x"), type) + .build() + .toMap()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute converter provider failed while looking up " + type); + verifyNoInteractions(defaultProvider); + } + + @Test + @DisplayName("Default-first order blocks a later custom provider") + void toMap_whenDefaultProviderBeforeCustom_throwsConverterNotFoundAndSkipsCustom() { + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingCustomProvider customProvider = new RecordingCustomProvider(); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders( + AttributeConverterProvider.defaultProvider(), + customProvider) + .put("value", new CustomType("x"), type) + .build() + .toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(customProvider.requestedTypes()).isEmpty(); + } + + @Test + @DisplayName("A configured default provider propagates its direct failure for an unsupported type") + void toMap_whenUnsupportedTypeWithDefaultProvider_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + assertThatThrownBy(() -> defaultBuilder().put("value", new UnsupportedType("x"), type).build().toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("EnhancedType Object fails converter lookup") + void toMap_whenObjectEnhancedType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(Object.class); + + assertThatThrownBy(() -> defaultBuilder().put("value", new Object(), type).build().toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.Object)"); + } + + @Test + @DisplayName("Class-based get rejects Object before provider lookup") + void get_whenObjectClass_throwsIllegalArgumentExceptionToUseGetList() { + EnhancedDocument document = defaultBuilder() + .put("value", "text", EnhancedType.of(String.class)) + .build(); + + assertThatThrownBy(() -> document.get("value", Object.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Values of type List are not supported by this API, please use the getList API instead"); + } + + @Test + @DisplayName("Class-based put rejects Object before lazy conversion") + void put_whenObjectClass_throwsIllegalArgumentExceptionToUsePutList() { + assertThatThrownBy(() -> EnhancedDocument.builder().put("value", new Object(), Object.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Values of type List are not supported by this API, please use the putList API instead"); + } + + @Test + @DisplayName("EnhancedDocument sends Collection to its list branch and reads an ArrayList") + void putThenGet_whenStringCollection_storesLAndReadsArrayList() { + EnhancedType> type = EnhancedType.collectionOf(String.class); + List input = new ArrayList<>(); + input.add("a"); + + EnhancedDocument document = defaultBuilder().put("value", input, type).build(); + Map map = document.toMap(); + Collection read = document.get("value", type); + + assertThat(map.get("value").hasL()).isTrue(); + assertThat(read).isInstanceOf(ArrayList.class).containsExactly("a"); + } + + @Test + @DisplayName("EnhancedDocument sends Iterable to its list branch and reads an ArrayList") + void putThenGet_whenStringIterable_storesLAndReadsArrayList() { + EnhancedType> type = new EnhancedType>() { }; + Iterable input = new ArrayList<>(Collections.singletonList("a")); + + EnhancedDocument document = defaultBuilder().put("value", input, type).build(); + Map map = document.toMap(); + Iterable read = document.get("value", type); + + assertThat(map.get("value").hasL()).isTrue(); + assertThat(read).isInstanceOf(ArrayList.class); + assertThat((List) read).containsExactly("a"); + } + + @Test + @DisplayName("A non-Collection Iterable fails in the document list converter") + void toMap_whenNonCollectionIterable_throwsClassCastException() { + EnhancedType> type = new EnhancedType>() { }; + Iterable input = new NonCollectionIterable(); + + assertThatThrownBy(() -> defaultBuilder().put("value", input, type).build().toMap()) + .isInstanceOf(ClassCastException.class) + .hasMessageContaining(NonCollectionIterable.class.getName()) + .hasMessageContaining("cannot be cast"); + } + + @Test + @DisplayName("Deque delegates to the default provider and remains unsupported") + void toMap_whenStringDeque_throwsConverterNotFound() { + EnhancedType> type = EnhancedType.dequeOf(String.class); + Deque input = new ArrayDeque<>(); + input.add("a"); + + assertThatThrownBy(() -> defaultBuilder().put("value", input, type).build().toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A concrete ArrayList token delegates to the default provider and remains unsupported") + void toMap_whenConcreteArrayList_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { }; + ArrayList input = new ArrayList<>(); + input.add("a"); + + assertThatThrownBy(() -> defaultBuilder().put("value", input, type).build().toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Document list recursion can use a custom member provider") + void putThenGet_whenListOfUnsupportedTypeWithMemberProvider_storesLWithSMembersAndReadsArrayList() { + EnhancedType> type = EnhancedType.listOf(UnsupportedType.class); + List input = new ArrayList<>(); + input.add(new UnsupportedType("a")); + input.add(new UnsupportedType("b")); + AttributeConverterProvider memberProvider = new UnsupportedMemberProvider(); + + EnhancedDocument document = EnhancedDocument.builder() + .attributeConverterProviders(memberProvider) + .put("value", input, type) + .build(); + Map map = document.toMap(); + List read = document.get("value", type); + + assertThat(map.get("value").hasL()).isTrue(); + assertThat(map.get("value").l()).containsExactly(AttributeValue.fromS("a"), AttributeValue.fromS("b")); + assertThat(read).isInstanceOf(ArrayList.class).isEqualTo(input); + } + + @Test + @DisplayName("Document map recursion can use a custom value provider") + void putThenGet_whenMapOfUnsupportedTypeWithValueProvider_storesMWithSMemberAndReadsLinkedHashMap() { + EnhancedType> type = + EnhancedType.mapOf(String.class, UnsupportedType.class); + Map input = new LinkedHashMap<>(); + input.put("key", new UnsupportedType("a")); + AttributeConverterProvider memberProvider = new UnsupportedMemberProvider(); + + EnhancedDocument document = EnhancedDocument.builder() + .attributeConverterProviders(memberProvider) + .put("value", input, type) + .build(); + Map map = document.toMap(); + Map read = document.get("value", type); + + assertThat(map.get("value").hasM()).isTrue(); + assertThat(map.get("value").m().get("key")).isEqualTo(AttributeValue.fromS("a")); + assertThat(read).isInstanceOf(LinkedHashMap.class).isEqualTo(input); + } + + @Test + @DisplayName("A document list of Object fails converter lookup for its member type") + void toMap_whenListOfObject_throwsConverterNotFoundForMemberType() { + EnhancedType> type = EnhancedType.listOf(Object.class); + List input = new ArrayList<>(); + input.add("a"); + + assertThatThrownBy(() -> defaultBuilder().put("value", input, type).build().toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.Object)"); + } + + @Test + @DisplayName("An explicit document null is present without converter lookup") + void putNull_whenWithoutProvider_isPresentAndStoredAsNul() { + EnhancedDocument document = EnhancedDocument.builder().putNull("value").build(); + + assertThat(document.isPresent("value")).isTrue(); + assertThat(document.isNull("value")).isTrue(); + assertThat(document.toMap()).containsEntry("value", AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("An absent document attribute differs from explicit null") + void get_whenAbsentAttribute_isNotPresentAndReturnsNull() { + EnhancedType type = EnhancedType.of(String.class); + EnhancedDocument document = EnhancedDocument.builder().build(); + + assertThat(document.isPresent("value")).isFalse(); + assertThat(document.isNull("value")).isFalse(); + assertThat(document.get("value", type)).isNull(); + } + + @Test + @DisplayName("Typed put rejects a null Java value") + void put_whenNullJavaValue_throwsNullPointerExceptionWithPutNullGuidance() { + EnhancedType type = EnhancedType.of(String.class); + + assertThatThrownBy(() -> EnhancedDocument.builder().put("value", null, type)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Value for value must not be null. Use putNull API to insert a Null value"); + } + + @Test + @DisplayName("Typed get maps explicit NULL to null while presence flags remain true") + void get_whenExplicitNulAttribute_returnsNullWhilePresentAndNullFlagsRemainTrue() { + EnhancedType type = EnhancedType.of(String.class); + EnhancedDocument document = defaultBuilder() + .put("value", AttributeValue.fromNul(true), EnhancedType.of(AttributeValue.class)) + .build(); + + assertThat(document.get("value", type)).isNull(); + assertThat(document.isPresent("value")).isTrue(); + assertThat(document.isNull("value")).isTrue(); + } + + @Test + @DisplayName("fromJson installs the shared default provider") + void fromJson_whenJsonObject_installsDefaultProviderAndReadsString() { + EnhancedType type = EnhancedType.of(String.class); + + EnhancedDocument document = EnhancedDocument.fromJson("{\"value\":\"text\"}"); + + assertThat(document.attributeConverterProviders()) + .contains(AttributeConverterProvider.defaultProvider()); + assertThat(document.get("value", type)).isEqualTo("text"); + } + + @Test + @DisplayName("fromAttributeValueMap installs the shared default provider") + void fromAttributeValueMap_whenNumericAttribute_installsDefaultProviderAndReadsInteger() { + EnhancedType type = EnhancedType.of(Integer.class); + Map input = Collections.singletonMap("value", AttributeValue.fromN("7")); + + EnhancedDocument document = EnhancedDocument.fromAttributeValueMap(input); + + assertThat(document.attributeConverterProviders()) + .contains(AttributeConverterProvider.defaultProvider()); + assertThat(document.get("value", type)).isEqualTo(7); + } + + @Test + @DisplayName("An AttributeValue escape path stores and returns the exact value without a provider") + void put_whenAttributeValueMap_storesAndReturnsExactValueWithoutProvider() { + AttributeValue input = AttributeValue.fromM( + Collections.singletonMap("inner", AttributeValue.fromS("text"))); + + EnhancedDocument document = EnhancedDocument.builder() + .attributeValueMap(Collections.singletonMap("value", input)) + .build(); + + assertThat(document.toMap().get("value")).isEqualTo(input); + assertThat(document.get("value", EnhancedType.of(AttributeValue.class))).isEqualTo(input); + } + + @Test + @DisplayName("A null EnhancedType fails only when lazy conversion runs") + void toMap_whenNullEnhancedType_buildSucceedsThenThrowsNullPointerException() { + EnhancedDocument document = EnhancedDocument.builder() + .put("value", "text", (EnhancedType) null) + .build(); + + assertThat(document).isNotNull(); + assertThatThrownBy(document::toMap) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("A typed concrete HashMap differs from the base-map helper and remains unsupported") + void toMap_whenConcreteHashMap_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { }; + HashMap input = new HashMap<>(); + input.put("count", 7); + + assertThatThrownBy(() -> defaultBuilder().put("value", input, type).build().toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Object validation fails before a custom provider is invoked") + void toMap_whenObjectTypeWithObjectProvider_throwsConverterNotFoundWithoutInvokingProvider() { + EnhancedType type = EnhancedType.of(Object.class); + AttributeConverterProvider objectProvider = mock(AttributeConverterProvider.class); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders( + objectProvider, + AttributeConverterProvider.defaultProvider()) + .put("value", new Object(), type) + .build() + .toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.Object)"); + verifyNoInteractions(objectProvider); + } + + @Test + @DisplayName("A custom provider cannot intercept a schema-bearing Object token because list-first throws first") + void toMap_whenSchemaBearingObjectWithMatchingProvider_throwsNullPointerExceptionWithoutInvokingProvider() { + TableSchema objectSchema = mock(TableSchema.class); + EnhancedType type = EnhancedType.documentOf(Object.class, objectSchema); + AttributeConverterProvider objectProvider = mock(AttributeConverterProvider.class); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders( + objectProvider, + AttributeConverterProvider.defaultProvider()) + .put("value", new Object(), type) + .build() + .toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for EnhancedType(java.lang.Object)"); + verifyNoInteractions(objectProvider); + verifyNoInteractions(objectSchema); + } + + @Test + @DisplayName("A throwing list-member provider exception propagates") + void toMap_whenListOfUnsupportedTypeWithThrowingMemberProvider_propagatesNestedProviderFailure() { + EnhancedType> type = EnhancedType.listOf(UnsupportedType.class); + List input = new ArrayList<>(); + input.add(new UnsupportedType("a")); + AttributeConverterProvider provider = new NestedThrowingProvider(); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders(provider) + .put("value", input, type) + .build() + .toMap()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute converter provider failed while looking up " + EnhancedType.of(UnsupportedType.class)); + } + + @Test + @DisplayName("A throwing map-value provider exception propagates") + void toMap_whenMapOfUnsupportedTypeWithThrowingValueProvider_propagatesNestedProviderFailure() { + EnhancedType> type = + EnhancedType.mapOf(String.class, UnsupportedType.class); + Map input = Collections.singletonMap("key", new UnsupportedType("a")); + AttributeConverterProvider provider = new NestedThrowingProvider(); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders(provider) + .put("value", input, type) + .build() + .toMap()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute converter provider failed while looking up " + EnhancedType.of(UnsupportedType.class)); + } + + @Test + @DisplayName("A throwing set-member provider is not reached because default recursive lookup fails") + void toMap_whenSetOfUnsupportedTypeWithThrowingMemberProvider_recordsOnlyOuterSetAndThrowsConverterNotFound() { + EnhancedType> type = EnhancedType.setOf(UnsupportedType.class); + Set input = new LinkedHashSet<>(); + input.add(new UnsupportedType("a")); + NestedThrowingProvider provider = new NestedThrowingProvider(); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders( + provider, + AttributeConverterProvider.defaultProvider()) + .put("value", input, type) + .build() + .toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(provider.requestedTypes()).containsExactly(type); + } + + @Test + @DisplayName("A null list-member provider result names the member type in the document error") + void toMap_whenListOfUnsupportedTypeWithNullMemberProvider_throwsDocumentConverterNotFoundForMember() { + EnhancedType> type = EnhancedType.listOf(UnsupportedType.class); + EnhancedType memberType = EnhancedType.of(UnsupportedType.class); + List input = Collections.singletonList(new UnsupportedType("a")); + AttributeConverterProvider provider = new ReturningNullProvider(); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders(provider) + .put("value", input, type) + .build() + .toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage(documentConverterNotFoundMessage(memberType)); + } + + @Test + @DisplayName("A null map-value provider result names the value type in the document error") + void toMap_whenMapOfUnsupportedTypeWithNullValueProvider_throwsDocumentConverterNotFoundForValue() { + EnhancedType> type = + EnhancedType.mapOf(String.class, UnsupportedType.class); + EnhancedType valueType = EnhancedType.of(UnsupportedType.class); + Map input = Collections.singletonMap("key", new UnsupportedType("a")); + AttributeConverterProvider provider = new ReturningNullProvider(); + + assertThatThrownBy(() -> EnhancedDocument.builder() + .attributeConverterProviders(provider) + .put("value", input, type) + .build() + .toMap()) + .isInstanceOf(IllegalStateException.class) + .hasMessage(documentConverterNotFoundMessage(valueType)); + } + + private static EnhancedDocument.Builder defaultBuilder() { + return EnhancedDocument.builder() + .attributeConverterProviders(AttributeConverterProvider.defaultProvider()); + } + + private static TableSchema documentSchema() { + return StaticTableSchema.builder(DocumentType.class) + .newItemSupplier(DocumentType::new) + .addAttribute(String.class, a -> a.name("name") + .getter(DocumentType::getName) + .setter(DocumentType::setName)) + .build(); + } + + private static String documentConverterNotFoundMessage(EnhancedType type) { + return "AttributeConverter not found for class " + type + + ". Please add an AttributeConverterProvider for this type. If it is a default type, add the " + + "DefaultAttributeConverterProvider to the builder."; + } + + enum TestEnum { + OPEN, + CLOSED + } + + static final class CustomType { + private final String value; + + CustomType(String value) { + this.value = value; + } + + String value() { + return value; + } + } + + static final class UnsupportedType { + private final String value; + + UnsupportedType(String value) { + this.value = value; + } + + String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof UnsupportedType)) { + return false; + } + UnsupportedType that = (UnsupportedType) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + } + + static final class DocumentType { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + static final class ReturningNullProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return null; + } + + List> requestedTypes() { + return requestedTypes; + } + } + + static final class NonCollectionIterable implements Iterable { + @Override + public Iterator iterator() { + return Collections.singletonList("a").iterator(); + } + } + + static final class CustomTypeConverter implements AttributeConverter { + private final String prefix; + + CustomTypeConverter(String prefix) { + this.prefix = prefix; + } + + @Override + public AttributeValue transformFrom(CustomType input) { + return AttributeValue.fromS(prefix + ":" + input.value()); + } + + @Override + public CustomType transformTo(AttributeValue input) { + String text = input.s(); + return new CustomType(text.substring(prefix.length() + 1)); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(CustomType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + static final class UnsupportedStringConverter implements AttributeConverter { + @Override + public AttributeValue transformFrom(UnsupportedType input) { + return AttributeValue.fromS(input.value()); + } + + @Override + public UnsupportedType transformTo(AttributeValue input) { + return new UnsupportedType(input.s()); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(UnsupportedType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + static final class RecordingCustomProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + private final CustomTypeConverter converter = new CustomTypeConverter("custom"); + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(CustomType.class).equals(enhancedType)) { + return (AttributeConverter) converter; + } + return null; + } + + List> requestedTypes() { + return requestedTypes; + } + + CustomTypeConverter converter() { + return converter; + } + } + + static final class UnsupportedMemberProvider implements AttributeConverterProvider { + private final UnsupportedStringConverter converter = new UnsupportedStringConverter(); + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + if (EnhancedType.of(UnsupportedType.class).equals(enhancedType)) { + return (AttributeConverter) converter; + } + return null; + } + } + + static final class ThrowingProvider implements AttributeConverterProvider { + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + throw new IllegalArgumentException("Attribute converter provider failed while looking up " + enhancedType); + } + } + + static final class NestedThrowingProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(UnsupportedType.class).equals(enhancedType)) { + throw new IllegalArgumentException("Attribute converter provider failed while looking up " + enhancedType); + } + return null; + } + + List> requestedTypes() { + return requestedTypes; + } + } + +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ImmutableSchemaConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ImmutableSchemaConverterTest.java new file mode 100644 index 000000000000..ae59304aca39 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/ImmutableSchemaConverterTest.java @@ -0,0 +1,1682 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DocumentAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.IntegerAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.ImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests converter selection and item conversion for immutable table schemas. + *

+ * The tests cover scalar, enumeration, collection, map, and nested immutable attributes. They also verify null + * handling, schema caching, declared converter providers, attribute converters, provider precedence, and unsupported + * attribute types during schema creation and item conversion. + */ +public class ImmutableSchemaConverterTest { + + @BeforeEach + void setUp() { + clearSchemaCache(BeanTableSchema.class); + clearSchemaCache(ImmutableTableSchema.class); + RecordingCustomProvider.reset(); + ReturningNullProvider.reset(); + ThrowingProvider.reset(); + ObjectProvider.reset(); + UnsupportedTypeOnlyProvider.reset(); + } + + private static void clearSchemaCache(Class schemaClass) { + try { + Method method = schemaClass.getDeclaredMethod("clearSchemaCache"); + method.setAccessible(true); + method.invoke(null); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + @Test + @DisplayName("A nonnull integer property selects IntegerAttributeConverter and round-trips") + void fromImmutableClass_whenNonnullInteger_selectsIntegerConverterAndRoundTrips() { + TableSchema schema = TableSchema.fromImmutableClass(IntegerModel.class); + IntegerModel model = IntegerModel.builder().id("id-1").value(42).build(); + + Map map = schema.itemToMap(model, true); + IntegerModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(IntegerAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.N); + assertThat(map.get("value").n()).isEqualTo("42"); + assertThat(read.value()).isEqualTo(42); + } + + @Test + @DisplayName("A string set is stored as SS and rebuilt as a LinkedHashSet") + void fromImmutableClass_whenStringSet_selectsSetConverterAndRebuildsLinkedHashSet() { + TableSchema schema = TableSchema.fromImmutableClass(StringSetModel.class); + Set input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + StringSetModel model = StringSetModel.builder().id("id-1").value(input).build(); + + Map map = schema.itemToMap(model, true); + StringSetModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.value()).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("A nested immutable property selects DocumentAttributeConverter and is rebuilt") + void fromImmutableClass_whenNestedImmutable_selectsDocumentConverterAndRebuildsNestedValue() { + TableSchema schema = TableSchema.fromImmutableClass(NestedOuterModel.class); + NestedInnerModel nested = NestedInnerModel.builder().nestedValue("inner").build(); + NestedOuterModel model = NestedOuterModel.builder().id("id-1").value(nested).build(); + + Map map = schema.itemToMap(model, true); + NestedOuterModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(DocumentAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(read.value().nestedValue()).isEqualTo("inner"); + } + + @Test + @DisplayName("itemToMap with ignoreNulls false stores a null string as DynamoDB NULL") + void itemToMap_whenNullStringIgnoreNullsFalse_includesNulValue() { + TableSchema schema = TableSchema.fromImmutableClass(StringModel.class); + StringModel model = StringModel.builder().id("id-1").build(); + + Map map = schema.itemToMap(model, false); + + assertThat(map).containsEntry("value", AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("itemToMap with ignoreNulls true omits a null string property") + void itemToMap_whenNullStringIgnoreNullsTrue_omitsValue() { + TableSchema schema = TableSchema.fromImmutableClass(StringModel.class); + StringModel model = StringModel.builder().id("id-1").build(); + + Map map = schema.itemToMap(model, true); + + assertThat(map).doesNotContainKey("value"); + } + + @Test + @DisplayName("mapToItem skips the builder method for a DynamoDB NULL string property") + void mapToItem_whenNulString_doesNotCallBuilderValueAndLeavesValueNull() { + TableSchema schema = TableSchema.fromImmutableClass(NullReadModel.class); + Map map = new HashMap<>(); + map.put("id", AttributeValue.fromS("id-1")); + map.put("value", AttributeValue.fromNul(true)); + NullReadModel.Builder.resetValueCalls(); + + NullReadModel read = schema.mapToItem(map); + + assertThat(read).isNotNull(); + assertThat(NullReadModel.Builder.valueCalls()).isZero(); + assertThat(read.value()).isNull(); + } + + @Test + @DisplayName("An unconverted Object property fails converter lookup") + void fromImmutableClass_whenUnconvertedObject_throwsConverterNotFound() { + assertThatThrownBy(() -> TableSchema.fromImmutableClass(ObjectModel.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("An unconverted UnsupportedType property fails converter lookup") + void fromImmutableClass_whenUnconvertedUnsupportedType_throwsIllegalStateException() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + assertThatThrownBy(() -> TableSchema.fromImmutableClass(UnsupportedModel.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A custom provider listed first is consulted twice and supplies CustomTypeConverter") + void fromImmutableClass_whenCustomProviderFirst_invokesCustomTwiceAndRoundTrips() { + TableSchema schema = TableSchema.fromImmutableClass(CustomFirstModel.class); + CustomType input = new CustomType("x"); + CustomFirstModel model = CustomFirstModel.builder().value(input).build(); + + Map map = schema.itemToMap(model, true); + CustomFirstModel read = schema.mapToItem(map); + + assertThat(RecordingCustomProvider.current().requestedTypes()) + .hasSize(2) + .containsExactly(EnhancedType.of(CustomType.class), EnhancedType.of(CustomType.class)); + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(CustomTypeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.value()).isEqualTo(input); + } + + @Test + @DisplayName("A null-returning provider is consulted once then the default string converter is used") + void fromImmutableClass_whenNullReturningProviderThenDefault_invokesProviderOnceAndSelectsStringConverter() { + TableSchema schema = + TableSchema.fromImmutableClass(NullThenDefaultModel.class); + + assertThat(ReturningNullProvider.current().requestedTypes()) + .containsExactly(EnhancedType.of(String.class)); + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(StringAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + } + + @Test + @DisplayName("Default-first ordering throws before a later custom provider is consulted") + void fromImmutableClass_whenDefaultProviderFirst_throwsAndDoesNotInvokeCustomProvider() { + EnhancedType type = EnhancedType.of(CustomType.class); + + assertThatThrownBy(() -> TableSchema.fromImmutableClass(DefaultThenCustomModel.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + RecordingCustomProvider custom = RecordingCustomProvider.current(); + assertThat(custom == null || custom.requestedTypes().isEmpty()).isTrue(); + } + + @Test + @DisplayName("A throwing provider failure is propagated without consulting the default provider") + void fromImmutableClass_whenThrowingProviderThenDefault_propagatesProviderFailure() { + EnhancedType type = EnhancedType.of(CustomType.class); + + assertThatThrownBy(() -> TableSchema.fromImmutableClass(ThrowingThenDefaultModel.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute converter provider failed while looking up " + type); + } + + @Test + @DisplayName("Immutable schema factory caches by model class") + void fromImmutableClass_whenSameClassTwice_returnsSameImmutableTableSchemaReference() { + ImmutableTableSchema first = TableSchema.fromImmutableClass(StringModel.class); + ImmutableTableSchema second = TableSchema.fromImmutableClass(StringModel.class); + + assertThat(first).isSameAs(second); + assertThat(first).isInstanceOf(ImmutableTableSchema.class); + } + + @Test + @DisplayName("Generic schema factory dispatches an immutable class") + void fromClass_whenImmutableModel_returnsImmutableTableSchemaWithStringConverter() { + StringModel item = StringModel.builder().id("id-1").value("text").build(); + + TableSchema schema = TableSchema.fromClass(StringModel.class); + Map map = schema.itemToMap(item, true); + + assertThat(schema).isInstanceOf(ImmutableTableSchema.class); + assertThat(schema.converterForAttribute("value")).isInstanceOf(StringAttributeConverter.class); + assertThat(map.get("value").s()).isEqualTo("text"); + } + + @Test + @DisplayName("Immutable ObjectProvider before default selects ObjectStringConverter") + void fromImmutableClass_whenObjectProviderBeforeDefault_selectsObjectStringConverter() { + ObjectProviderModel item = ObjectProviderModel.builder().value(new Object()).build(); + + TableSchema schema = TableSchema.fromImmutableClass(ObjectProviderModel.class); + Map map = schema.itemToMap(item, true); + ObjectProviderModel read = schema.mapToItem(map); + + int objectRequestCount = 0; + for (EnhancedType requestedType : ObjectProvider.current().requestedTypes()) { + if (EnhancedType.of(Object.class).equals(requestedType)) { + objectRequestCount++; + } + } + assertThat(objectRequestCount).isEqualTo(2); + assertThat(schema.converterForAttribute("value")).isInstanceOf(ObjectStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.value()).isEqualTo("custom"); + } + + @Test + @DisplayName("ConvertedBy intercepts Object and does not consult the schema provider for that type") + void fromImmutableClass_whenObjectConvertedBy_selectsObjectStringConverterAndSkipsProvider() { + TableSchema schema = + TableSchema.fromImmutableClass(ConvertedObjectModel.class); + ConvertedObjectModel model = ConvertedObjectModel.builder().value(new Object()).build(); + + Map map = schema.itemToMap(model, true); + ConvertedObjectModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(ObjectStringConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(ObjectProvider.current().requestedTypes()).doesNotContain(EnhancedType.of(Object.class)); + assertThat(read.value()).isEqualTo("custom"); + } + + @Test + @DisplayName("ConvertedBy intercepts UnsupportedType with UnsupportedStringConverter") + void fromImmutableClass_whenUnsupportedConvertedBy_selectsUnsupportedStringConverterAndRoundTrips() { + TableSchema schema = + TableSchema.fromImmutableClass(ConvertedUnsupportedModel.class); + UnsupportedType input = new UnsupportedType(); + ConvertedUnsupportedModel model = ConvertedUnsupportedModel.builder().value(input).build(); + + Map map = schema.itemToMap(model, true); + ConvertedUnsupportedModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(UnsupportedStringConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.value()).isEqualTo(input); + } + + @Test + @DisplayName("ConvertedBy intercepts HashSet and the builder receives a HashSet") + void fromImmutableClass_whenHashSetConvertedBy_selectsHashSetConverterAndBuilderReceivesHashSet() { + TableSchema schema = + TableSchema.fromImmutableClass(ConvertedHashSetModel.class); + HashSet input = new HashSet<>(); + input.add("a"); + input.add("b"); + ConvertedHashSetModel model = ConvertedHashSetModel.builder().value(input).build(); + + Map map = schema.itemToMap(model, true); + ConvertedHashSetModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(HashSetConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.value()).isInstanceOf(HashSet.class) + .containsExactlyInAnyOrder("a", "b"); + } + + @Test + @DisplayName("An empty provider list fails with NPE when no attribute converter is present") + void fromImmutableClass_whenEmptyProvidersWithoutConvertedBy_throwsNullPointerException() { + assertThatThrownBy(() -> TableSchema.fromImmutableClass(EmptyProvidersModel.class)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("ConvertedBy still works when the immutable type declares an empty provider list") + void fromImmutableClass_whenEmptyProvidersWithConvertedBy_usesCustomStringConverter() { + TableSchema schema = + TableSchema.fromImmutableClass(EmptyProvidersConvertedModel.class); + EmptyProvidersConvertedModel model = EmptyProvidersConvertedModel.builder().value("text").build(); + + Map map = schema.itemToMap(model, true); + EmptyProvidersConvertedModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(CustomStringConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("custom:text"); + assertThat(read.value()).isEqualTo("text"); + } + + @Test + @DisplayName("An unconverted HashSet property fails lookup before builder assignment") + void fromImmutableClass_whenUnconvertedHashSet_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> TableSchema.fromImmutableClass(HashSetModel.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A custom provider is not consulted for set members during default fallback") + void fromImmutableClass_whenUnsupportedSetWithCustomMemberProvider_throwsForEnclosingSetType() { + EnhancedType> type = EnhancedType.setOf(UnsupportedType.class); + + assertThatThrownBy(() -> TableSchema.fromImmutableClass(UnsupportedSetModel.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(UnsupportedTypeOnlyProvider.current().requestedTypes()).hasSize(1); + EnhancedType requestedType = UnsupportedTypeOnlyProvider.current().requestedTypes().get(0); + assertThat(requestedType.rawClass()).isEqualTo(Set.class); + assertThat(requestedType.rawClassParameters()).containsExactly(EnhancedType.of(UnsupportedType.class)); + } + + @Test + @DisplayName("A Collection of String is stored as SS and rebuilt as a LinkedHashSet") + void fromImmutableClass_whenStringCollection_selectsSetConverterAndRebuildsLinkedHashSet() { + TableSchema schema = TableSchema.fromImmutableClass(StringCollectionModel.class); + Collection input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + StringCollectionModel model = StringCollectionModel.builder().value(input).build(); + + Map map = schema.itemToMap(model, true); + StringCollectionModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(SetAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.value()).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("A string list with a duplicate is stored as L and rebuilt as an ArrayList") + void fromImmutableClass_whenStringListWithDuplicate_preservesOrderAndDuplicateAsArrayList() { + TableSchema schema = TableSchema.fromImmutableClass(StringListModel.class); + List input = new ArrayList<>(); + input.add("a"); + input.add("b"); + input.add("a"); + StringListModel model = StringListModel.builder().id("id-1").value(input).build(); + + Map map = schema.itemToMap(model, true); + StringListModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(ListAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(read.value()).isInstanceOf(ArrayList.class) + .containsExactly("a", "b", "a"); + } + + @Test + @DisplayName("A string-to-integer map is stored as M and rebuilt as a LinkedHashMap") + void fromImmutableClass_whenStringIntegerMap_selectsMapConverterAndRebuildsLinkedHashMap() { + TableSchema schema = + TableSchema.fromImmutableClass(StringIntegerMapModel.class); + Map input = new LinkedHashMap<>(); + input.put("a", 1); + input.put("b", 2); + StringIntegerMapModel model = StringIntegerMapModel.builder().id("id-1").value(input).build(); + + Map map = schema.itemToMap(model, true); + StringIntegerMapModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(MapAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(read.value()).isEqualTo(input).isInstanceOf(LinkedHashMap.class); + } + + @Test + @DisplayName("An enum property selects EnumAttributeConverter and round-trips") + void fromImmutableClass_whenEnumProperty_selectsEnumConverterAndRoundTrips() { + TableSchema schema = TableSchema.fromImmutableClass(EnumModel.class); + EnumModel model = EnumModel.builder().id("id-1").value(TestEnum.OPEN).build(); + + Map map = schema.itemToMap(model, true); + EnumModel read = schema.mapToItem(map); + + AttributeConverter converter = schema.converterForAttribute("value"); + assertThat(converter).isInstanceOf(EnumAttributeConverter.class); + assertThat(converter.attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("OPEN"); + assertThat(read.value()).isEqualTo(TestEnum.OPEN); + } + + @Test + @DisplayName("An unconverted HashMap property fails lookup before builder assignment") + void fromImmutableClass_whenUnconvertedHashMap_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> TableSchema.fromImmutableClass(HashMapModel.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @DynamoDbImmutable(builder = IntegerModel.Builder.class) + public static final class IntegerModel { + private final String id; + private final Integer value; + + private IntegerModel(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public Integer value() { + return value; + } + + public static final class Builder { + private String id; + private Integer value; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(Integer value) { + this.value = value; + return this; + } + + public IntegerModel build() { + return new IntegerModel(this); + } + } + } + + @DynamoDbImmutable(builder = StringSetModel.Builder.class) + public static final class StringSetModel { + private final String id; + private final Set value; + + private StringSetModel(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public Set value() { + return value; + } + + public static final class Builder { + private String id; + private Set value; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(Set value) { + this.value = value; + return this; + } + + public StringSetModel build() { + return new StringSetModel(this); + } + } + } + + @DynamoDbImmutable(builder = NestedInnerModel.Builder.class) + public static final class NestedInnerModel { + private final String nestedValue; + + private NestedInnerModel(Builder b) { + this.nestedValue = b.nestedValue; + } + + public static Builder builder() { + return new Builder(); + } + + public String nestedValue() { + return nestedValue; + } + + public static final class Builder { + private String nestedValue; + + public Builder nestedValue(String nestedValue) { + this.nestedValue = nestedValue; + return this; + } + + public NestedInnerModel build() { + return new NestedInnerModel(this); + } + } + } + + @DynamoDbImmutable(builder = NestedOuterModel.Builder.class) + public static final class NestedOuterModel { + private final String id; + private final NestedInnerModel value; + + private NestedOuterModel(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public NestedInnerModel value() { + return value; + } + + public static final class Builder { + private String id; + private NestedInnerModel value; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(NestedInnerModel value) { + this.value = value; + return this; + } + + public NestedOuterModel build() { + return new NestedOuterModel(this); + } + } + } + + @DynamoDbImmutable(builder = StringModel.Builder.class) + public static final class StringModel { + private final String id; + private final String value; + + private StringModel(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public String value() { + return value; + } + + public static final class Builder { + private String id; + private String value; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(String value) { + this.value = value; + return this; + } + + public StringModel build() { + return new StringModel(this); + } + } + } + + @DynamoDbImmutable(builder = NullReadModel.Builder.class) + public static final class NullReadModel { + private final String id; + private final String value; + + private NullReadModel(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public String value() { + return value; + } + + public static final class Builder { + private static final ThreadLocal VALUE_CALLS = ThreadLocal.withInitial(() -> 0); + private String id; + private String value; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(String value) { + VALUE_CALLS.set(VALUE_CALLS.get() + 1); + this.value = value; + return this; + } + + static int valueCalls() { + return VALUE_CALLS.get(); + } + + static void resetValueCalls() { + VALUE_CALLS.remove(); + } + + public NullReadModel build() { + return new NullReadModel(this); + } + } + } + + @DynamoDbImmutable(builder = ObjectModel.Builder.class) + public static final class ObjectModel { + private final Object value; + + private ObjectModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public Object value() { + return value; + } + + public static final class Builder { + private Object value; + + public Builder value(Object value) { + this.value = value; + return this; + } + + public ObjectModel build() { + return new ObjectModel(this); + } + } + } + + @DynamoDbImmutable(builder = UnsupportedModel.Builder.class) + public static final class UnsupportedModel { + private final UnsupportedType value; + + private UnsupportedModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public UnsupportedType value() { + return value; + } + + public static final class Builder { + private UnsupportedType value; + + public Builder value(UnsupportedType value) { + this.value = value; + return this; + } + + public UnsupportedModel build() { + return new UnsupportedModel(this); + } + } + } + + @DynamoDbImmutable(builder = CustomFirstModel.Builder.class, converterProviders = { + RecordingCustomProvider.class, + DefaultAttributeConverterProvider.class + }) + public static final class CustomFirstModel { + private final CustomType value; + + private CustomFirstModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public CustomType value() { + return value; + } + + public static final class Builder { + private CustomType value; + + public Builder value(CustomType value) { + this.value = value; + return this; + } + + public CustomFirstModel build() { + return new CustomFirstModel(this); + } + } + } + + @DynamoDbImmutable(builder = NullThenDefaultModel.Builder.class, converterProviders = { + ReturningNullProvider.class, + DefaultAttributeConverterProvider.class + }) + public static final class NullThenDefaultModel { + private final String value; + + private NullThenDefaultModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String value() { + return value; + } + + public static final class Builder { + private String value; + + public Builder value(String value) { + this.value = value; + return this; + } + + public NullThenDefaultModel build() { + return new NullThenDefaultModel(this); + } + } + } + + @DynamoDbImmutable(builder = DefaultThenCustomModel.Builder.class, converterProviders = { + DefaultAttributeConverterProvider.class, + RecordingCustomProvider.class + }) + public static final class DefaultThenCustomModel { + private final CustomType value; + + private DefaultThenCustomModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public CustomType value() { + return value; + } + + public static final class Builder { + private CustomType value; + + public Builder value(CustomType value) { + this.value = value; + return this; + } + + public DefaultThenCustomModel build() { + return new DefaultThenCustomModel(this); + } + } + } + + @DynamoDbImmutable(builder = ThrowingThenDefaultModel.Builder.class, converterProviders = { + ThrowingProvider.class, + DefaultAttributeConverterProvider.class + }) + public static final class ThrowingThenDefaultModel { + private final CustomType value; + + private ThrowingThenDefaultModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public CustomType value() { + return value; + } + + public static final class Builder { + private CustomType value; + + public Builder value(CustomType value) { + this.value = value; + return this; + } + + public ThrowingThenDefaultModel build() { + return new ThrowingThenDefaultModel(this); + } + } + } + + @DynamoDbImmutable(builder = ObjectProviderModel.Builder.class, converterProviders = { + ObjectProvider.class, + DefaultAttributeConverterProvider.class + }) + public static final class ObjectProviderModel { + private final Object value; + + private ObjectProviderModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public Object value() { + return value; + } + + public static final class Builder { + private Object value; + + public Builder value(Object value) { + this.value = value; + return this; + } + + public ObjectProviderModel build() { + return new ObjectProviderModel(this); + } + } + } + + @DynamoDbImmutable(builder = ConvertedObjectModel.Builder.class, converterProviders = { + ObjectProvider.class, + DefaultAttributeConverterProvider.class + }) + public static final class ConvertedObjectModel { + private final Object value; + + private ConvertedObjectModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbConvertedBy(ObjectStringConverter.class) + public Object value() { + return value; + } + + public static final class Builder { + private Object value; + + public Builder value(Object value) { + this.value = value; + return this; + } + + public ConvertedObjectModel build() { + return new ConvertedObjectModel(this); + } + } + } + + @DynamoDbImmutable(builder = ConvertedUnsupportedModel.Builder.class) + public static final class ConvertedUnsupportedModel { + private final UnsupportedType value; + + private ConvertedUnsupportedModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbConvertedBy(UnsupportedStringConverter.class) + public UnsupportedType value() { + return value; + } + + public static final class Builder { + private UnsupportedType value; + + public Builder value(UnsupportedType value) { + this.value = value; + return this; + } + + public ConvertedUnsupportedModel build() { + return new ConvertedUnsupportedModel(this); + } + } + } + + @DynamoDbImmutable(builder = ConvertedHashSetModel.Builder.class) + public static final class ConvertedHashSetModel { + private final HashSet value; + + private ConvertedHashSetModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbConvertedBy(HashSetConverter.class) + public HashSet value() { + return value; + } + + public static final class Builder { + private HashSet value; + + public Builder value(HashSet value) { + this.value = value; + return this; + } + + public ConvertedHashSetModel build() { + return new ConvertedHashSetModel(this); + } + } + } + + @DynamoDbImmutable(builder = EmptyProvidersModel.Builder.class, converterProviders = {}) + public static final class EmptyProvidersModel { + private final String value; + + private EmptyProvidersModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String value() { + return value; + } + + public static final class Builder { + private String value; + + public Builder value(String value) { + this.value = value; + return this; + } + + public EmptyProvidersModel build() { + return new EmptyProvidersModel(this); + } + } + } + + @DynamoDbImmutable(builder = EmptyProvidersConvertedModel.Builder.class, converterProviders = {}) + public static final class EmptyProvidersConvertedModel { + private final String value; + + private EmptyProvidersConvertedModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbConvertedBy(CustomStringConverter.class) + public String value() { + return value; + } + + public static final class Builder { + private String value; + + public Builder value(String value) { + this.value = value; + return this; + } + + public EmptyProvidersConvertedModel build() { + return new EmptyProvidersConvertedModel(this); + } + } + } + + @DynamoDbImmutable(builder = HashSetModel.Builder.class) + public static final class HashSetModel { + private final HashSet value; + + private HashSetModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public HashSet value() { + return value; + } + + public static final class Builder { + private HashSet value; + + public Builder value(HashSet value) { + this.value = value; + return this; + } + + public HashSetModel build() { + return new HashSetModel(this); + } + } + } + + @DynamoDbImmutable(builder = UnsupportedSetModel.Builder.class, converterProviders = { + UnsupportedTypeOnlyProvider.class, + DefaultAttributeConverterProvider.class + }) + public static final class UnsupportedSetModel { + private final Set value; + + private UnsupportedSetModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public Set value() { + return value; + } + + public static final class Builder { + private Set value; + + public Builder value(Set value) { + this.value = value; + return this; + } + + public UnsupportedSetModel build() { + return new UnsupportedSetModel(this); + } + } + } + + @DynamoDbImmutable(builder = StringCollectionModel.Builder.class) + public static final class StringCollectionModel { + private final Collection value; + + private StringCollectionModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public Collection value() { + return value; + } + + public static final class Builder { + private Collection value; + + public Builder value(Collection value) { + this.value = value; + return this; + } + + public StringCollectionModel build() { + return new StringCollectionModel(this); + } + } + } + + @DynamoDbImmutable(builder = StringListModel.Builder.class) + public static final class StringListModel { + private final String id; + private final List value; + + private StringListModel(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public List value() { + return value; + } + + public static final class Builder { + private String id; + private List value; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(List value) { + this.value = value; + return this; + } + + public StringListModel build() { + return new StringListModel(this); + } + } + } + + @DynamoDbImmutable(builder = StringIntegerMapModel.Builder.class) + public static final class StringIntegerMapModel { + private final String id; + private final Map value; + + private StringIntegerMapModel(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public Map value() { + return value; + } + + public static final class Builder { + private String id; + private Map value; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(Map value) { + this.value = value; + return this; + } + + public StringIntegerMapModel build() { + return new StringIntegerMapModel(this); + } + } + } + + @DynamoDbImmutable(builder = EnumModel.Builder.class) + public static final class EnumModel { + private final String id; + private final TestEnum value; + + private EnumModel(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public TestEnum value() { + return value; + } + + public static final class Builder { + private String id; + private TestEnum value; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(TestEnum value) { + this.value = value; + return this; + } + + public EnumModel build() { + return new EnumModel(this); + } + } + } + + @DynamoDbImmutable(builder = HashMapModel.Builder.class) + public static final class HashMapModel { + private final HashMap value; + + private HashMapModel(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public HashMap value() { + return value; + } + + public static final class Builder { + private HashMap value; + + public Builder value(HashMap value) { + this.value = value; + return this; + } + + public HashMapModel build() { + return new HashMapModel(this); + } + } + } + + public enum TestEnum { + OPEN, + CLOSED + } + + public static final class CustomType { + private final String value; + + public CustomType(String value) { + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CustomType)) { + return false; + } + CustomType that = (CustomType) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } + + public static final class UnsupportedType { + @Override + public boolean equals(Object o) { + return o instanceof UnsupportedType; + } + + @Override + public int hashCode() { + return 1; + } + } + + public static class CustomTypeConverter implements AttributeConverter { + public CustomTypeConverter() { + } + + @Override + public AttributeValue transformFrom(CustomType input) { + return AttributeValue.fromS("custom:" + input.value()); + } + + @Override + public CustomType transformTo(AttributeValue input) { + String stored = input.s(); + return new CustomType(stored.substring("custom:".length())); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(CustomType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static class CustomStringConverter implements AttributeConverter { + public CustomStringConverter() { + } + + @Override + public AttributeValue transformFrom(String input) { + return AttributeValue.fromS("custom:" + input); + } + + @Override + public String transformTo(AttributeValue input) { + String stored = input.s(); + if (stored != null && stored.startsWith("custom:")) { + return stored.substring("custom:".length()); + } + return stored; + } + + @Override + public EnhancedType type() { + return EnhancedType.of(String.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static class ObjectStringConverter implements AttributeConverter { + public ObjectStringConverter() { + } + + @Override + public AttributeValue transformFrom(Object input) { + return AttributeValue.fromS("custom"); + } + + @Override + public Object transformTo(AttributeValue input) { + return "custom"; + } + + @Override + public EnhancedType type() { + return EnhancedType.of(Object.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static class UnsupportedStringConverter implements AttributeConverter { + public UnsupportedStringConverter() { + } + + @Override + public AttributeValue transformFrom(UnsupportedType input) { + return AttributeValue.fromS("custom"); + } + + @Override + public UnsupportedType transformTo(AttributeValue input) { + return new UnsupportedType(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(UnsupportedType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static class HashSetConverter implements AttributeConverter> { + public HashSetConverter() { + } + + @Override + public AttributeValue transformFrom(HashSet input) { + return AttributeValue.fromSs(new ArrayList<>(input)); + } + + @Override + public HashSet transformTo(AttributeValue input) { + return new HashSet<>(input.ss()); + } + + @Override + public EnhancedType> type() { + return new EnhancedType>() { + }; + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.SS; + } + } + + public static class RecordingCustomProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public RecordingCustomProvider() { + CURRENT.set(this); + } + + public static RecordingCustomProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(CustomType.class).equals(enhancedType)) { + return (AttributeConverter) new CustomTypeConverter(); + } + return null; + } + } + + public static class ReturningNullProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public ReturningNullProvider() { + CURRENT.set(this); + } + + public static ReturningNullProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return null; + } + } + + public static class ThrowingProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + public ThrowingProvider() { + CURRENT.set(this); + } + + public static ThrowingProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + throw new IllegalArgumentException("Attribute converter provider failed while looking up " + enhancedType); + } + } + + public static class ObjectProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public ObjectProvider() { + CURRENT.set(this); + } + + public static ObjectProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(Object.class).equals(enhancedType)) { + return (AttributeConverter) new ObjectStringConverter(); + } + return null; + } + } + + public static class UnsupportedTypeOnlyProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public UnsupportedTypeOnlyProvider() { + CURRENT.set(this); + } + + public static UnsupportedTypeOnlyProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(UnsupportedType.class).equals(enhancedType)) { + return (AttributeConverter) new UnsupportedStringConverter(); + } + return null; + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/KeyConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/KeyConverterTest.java new file mode 100644 index 000000000000..131be420860a --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/KeyConverterTest.java @@ -0,0 +1,441 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.Period; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.OptionalDouble; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.core.SdkNumber; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests conversion of partition and sort key values supplied to a key builder. + *

+ * The tests cover supported scalar objects, runtime type selection, primitive optional values, null inputs, and + * unsupported application or collection types. They also verify that preconverted attribute values bypass provider + * lookup and retain scalar or nonscalar DynamoDB forms accepted by the builder. + */ +public class KeyConverterTest { + + @ParameterizedTest(name = "{0} object key stores the converted AttributeValue") + @MethodSource("supportedObjectKeys") + @DisplayName("Supported built-in object keys convert through addPartitionValue and addSortValue") + void addPartitionValue_whenSupportedBuiltInType_storesConvertedPartitionAndSortValues( + String typeName, Object input, AttributeValue expected) { + Key key = Key.builder().addPartitionValue(input).addSortValue(input).build(); + + assertThat(key.partitionKeyValue()).as(typeName).isEqualTo(expected); + assertThat(key.sortKeyValue()).as(typeName).contains(expected); + } + + @Test + @DisplayName("A ByteBuffer runtime subclass is rejected as a partition object key") + void addPartitionValue_whenByteBufferRuntimeSubclass_throwsUnsupportedTypeWithIllegalStateCause() { + ByteBuffer input = ByteBuffer.wrap(new byte[] {1, 2}); + + assertThatThrownBy(() -> Key.builder().addPartitionValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + input.getClass().getName()) + .hasCauseInstanceOf(IllegalStateException.class) + .getCause() + .hasMessage("Converter not found for " + EnhancedType.of(input.getClass())); + } + + @Test + @DisplayName("A CharSequence reference selects conversion by the runtime String class") + void addPartitionValue_whenCharSequenceReferringToString_storesSTextOnPartitionAndSort() { + CharSequence input = "text"; + + Key key = Key.builder().addPartitionValue(input).addSortValue(input).build(); + + assertThat(key.partitionKeyValue()).isEqualTo(AttributeValue.fromS("text")); + assertThat(key.sortKeyValue()).contains(AttributeValue.fromS("text")); + } + + @Test + @DisplayName("A ZoneId runtime subclass is rejected as a partition object key") + void addPartitionValue_whenZoneIdRuntimeSubclass_throwsUnsupportedTypeWithIllegalStateCause() { + ZoneId input = ZoneId.of("Europe/Paris"); + + assertThatThrownBy(() -> Key.builder().addPartitionValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + input.getClass().getName()) + .hasCauseInstanceOf(IllegalStateException.class) + .getCause() + .hasMessage("Converter not found for " + EnhancedType.of(input.getClass())); + } + + @Test + @DisplayName("The sort object overload also rejects a ByteBuffer runtime subclass") + void addSortValue_whenByteBufferRuntimeSubclass_throwsUnsupportedType() { + ByteBuffer input = ByteBuffer.wrap(new byte[] {1, 2}); + + assertThatThrownBy(() -> Key.builder().partitionValue("pk").addSortValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + input.getClass().getName()) + .hasCauseInstanceOf(IllegalStateException.class) + .getCause() + .hasMessage("Converter not found for " + EnhancedType.of(input.getClass())); + } + + @Test + @DisplayName("The sort object overload also rejects a ZoneId runtime subclass") + void addSortValue_whenZoneIdRuntimeSubclass_throwsUnsupportedType() { + ZoneId input = ZoneId.of("Europe/Paris"); + + assertThatThrownBy(() -> Key.builder().partitionValue("pk").addSortValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + input.getClass().getName()) + .hasCauseInstanceOf(IllegalStateException.class) + .getCause() + .hasMessage("Converter not found for " + EnhancedType.of(input.getClass())); + } + + @Test + @DisplayName("A custom CharSequence implementation does not inherit the interface registration") + void addPartitionValue_whenCustomCharSequence_throwsUnsupportedType() { + CustomCharSequence input = new CustomCharSequence("text"); + + assertThatThrownBy(() -> Key.builder().addPartitionValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + CustomCharSequence.class.getName()); + } + + @Test + @DisplayName("The sort overload also rejects a custom CharSequence implementation") + void addSortValue_whenCustomCharSequence_throwsUnsupportedType() { + CustomCharSequence input = new CustomCharSequence("text"); + + assertThatThrownBy(() -> Key.builder().partitionValue("pk").addSortValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + CustomCharSequence.class.getName()); + } + + @Test + @DisplayName("An Object partition value is rejected as unsupported") + void addPartitionValue_whenObject_throwsUnsupportedType() { + assertThatThrownBy(() -> Key.builder().addPartitionValue(new Object())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + Object.class.getName()); + } + + @Test + @DisplayName("An Object sort value is rejected as unsupported") + void addSortValue_whenObject_throwsUnsupportedType() { + assertThatThrownBy(() -> Key.builder().partitionValue("pk").addSortValue(new Object())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + Object.class.getName()); + } + + @Test + @DisplayName("A List object key is looked up as its concrete runtime class") + void addPartitionValue_whenArrayList_throwsUnsupportedTypeForConcreteClass() { + ArrayList input = new ArrayList<>(); + + assertThatThrownBy(() -> Key.builder().addPartitionValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: java.util.ArrayList"); + } + + @Test + @DisplayName("A Set object sort key is looked up as its concrete runtime class") + void addSortValue_whenHashSet_throwsUnsupportedTypeForConcreteClass() { + HashSet input = new HashSet<>(); + + assertThatThrownBy(() -> Key.builder().partitionValue("pk").addSortValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: java.util.HashSet"); + } + + @Test + @DisplayName("A Map object key is looked up as its concrete runtime class") + void addPartitionValue_whenHashMap_throwsUnsupportedTypeForConcreteClass() { + HashMap input = new HashMap<>(); + + assertThatThrownBy(() -> Key.builder().addPartitionValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: java.util.HashMap"); + } + + @Test + @DisplayName("An object key cannot attach a document schema") + void addPartitionValue_whenDocumentType_throwsUnsupportedType() { + DocumentType input = new DocumentType(); + + assertThatThrownBy(() -> Key.builder().addPartitionValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + DocumentType.class.getName()); + } + + @Test + @DisplayName("An unsupported application key is wrapped with the provider failure as cause") + void addPartitionValue_whenUnsupportedType_throwsUnsupportedTypeWithConverterNotFoundCause() { + UnsupportedType input = new UnsupportedType(); + + Throwable thrown = catchThrowable(() -> Key.builder().addPartitionValue(input)); + + assertThat(thrown).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + UnsupportedType.class.getName()) + .hasCauseInstanceOf(IllegalStateException.class); + assertThat(thrown.getCause()) + .hasMessage("Converter not found for " + EnhancedType.of(UnsupportedType.class)); + } + + @Test + @DisplayName("An object key ignores an attribute-level-only converter") + void addPartitionValue_whenAttributeOnlyKey_throwsUnsupportedType() { + AttributeOnlyKey input = new AttributeOnlyKey(); + + assertThatThrownBy(() -> Key.builder().addPartitionValue(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported type: " + AttributeOnlyKey.class.getName()); + } + + @Test + @DisplayName("A null object partition value has a dedicated message") + void addPartitionValue_whenNull_throwsPartitionKeyValueCannotBeNull() { + assertThatThrownBy(() -> Key.builder().addPartitionValue(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Partition key value cannot be null"); + } + + @Test + @DisplayName("A null object sort value has a dedicated message") + void addSortValue_whenNull_throwsSortKeyValueCannotBeNull() { + assertThatThrownBy(() -> Key.builder().partitionValue("pk").addSortValue(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Sort key value cannot be null"); + } + + @Test + @DisplayName("A preconverted partition value bypasses provider selection") + void partitionValue_whenPreconvertedStringAttributeValue_storesSameSValue() { + AttributeValue custom = AttributeValue.fromS("custom"); + + Key key = Key.builder().partitionValue(custom).build(); + + assertThat(key.partitionKeyValue()).isEqualTo(custom); + } + + @Test + @DisplayName("A preconverted sort value bypasses provider selection") + void sortValue_whenPreconvertedBinaryAttributeValue_storesSameBValue() { + AttributeValue custom = AttributeValue.fromB(SdkBytes.fromUtf8String("ab")); + + Key key = Key.builder().partitionValue("pk").sortValue(custom).build(); + + assertThat(key.sortKeyValue()).contains(custom); + } + + @Test + @DisplayName("A preconverted partition overload accepts a non-scalar map") + void partitionValue_whenPreconvertedMapAttributeValue_storesMapWithoutScalarValidation() { + AttributeValue custom = AttributeValue.fromM( + Collections.singletonMap("inner", AttributeValue.fromS("text"))); + + Key key = Key.builder().partitionValue(custom).build(); + + assertThat(key.partitionKeyValue().hasM()).isTrue(); + } + + @Test + @DisplayName("A preconverted NULL partition value is rejected") + void partitionValue_whenNulAttributeValue_throwsPartitionValueShouldNotBeNull() { + AttributeValue custom = AttributeValue.fromNul(true); + + assertThatThrownBy(() -> Key.builder().partitionValue(custom)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("partitionValue should not be null"); + } + + @Test + @DisplayName("A preconverted NULL sort value is retained") + void sortValue_whenNulAttributeValue_retainsNul() { + AttributeValue custom = AttributeValue.fromNul(true); + + Key key = Key.builder().partitionValue("pk").sortValue(custom).build(); + + assertThat(key.sortKeyValue()).contains(AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("A null AttributeValue partition overload is rejected") + void partitionValue_whenNullAttributeValue_throwsPartitionValueShouldNotBeNull() { + assertThatThrownBy(() -> Key.builder().partitionValue((AttributeValue) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("partitionValue should not be null"); + } + + @Test + @DisplayName("A null AttributeValue sort overload means no sort value") + void sortValue_whenNullAttributeValue_leavesSortKeyEmpty() { + Key key = Key.builder().partitionValue("pk").sortValue((AttributeValue) null).build(); + + assertThat(key.sortKeyValue()).isEmpty(); + } + + @Test + @DisplayName("An empty primitive optional produces a NULL object key value") + void addPartitionValue_whenEmptyOptionalInt_storesNul() { + OptionalInt input = OptionalInt.empty(); + + Key key = Key.builder().addPartitionValue(input).build(); + + assertThat(key.partitionKeyValue()).isEqualTo(AttributeValue.fromNul(true)); + } + + static Stream supportedObjectKeys() { + byte[] bytes = new byte[] {1, 2}; + SdkBytes sdkBytes = SdkBytes.fromUtf8String("ab"); + TestEnum enumConstant = TestEnum.OPEN; + Instant instant = Instant.parse("2020-01-02T03:04:05Z"); + OffsetDateTime offsetDateTime = OffsetDateTime.parse("2020-01-02T03:04:05+02:00"); + ZonedDateTime zonedDateTime = ZonedDateTime.parse("2020-01-02T03:04:05+01:00[Europe/Paris]"); + + return Stream.of( + Arguments.of("AtomicBoolean", new AtomicBoolean(true), AttributeValue.fromBool(true)), + Arguments.of("AtomicInteger", new AtomicInteger(7), AttributeValue.fromN("7")), + Arguments.of("AtomicLong", new AtomicLong(7L), AttributeValue.fromN("7")), + Arguments.of("BigDecimal", new BigDecimal("1.25"), AttributeValue.fromN("1.25")), + Arguments.of("BigInteger", new BigInteger("12345678901234567890"), + AttributeValue.fromN("12345678901234567890")), + Arguments.of("Boolean", Boolean.TRUE, AttributeValue.fromBool(true)), + Arguments.of("byte[]", bytes, AttributeValue.fromB(SdkBytes.fromByteArray(bytes))), + Arguments.of("Byte", Byte.valueOf((byte) 7), AttributeValue.fromN("7")), + Arguments.of("char[]", new char[] {'a', 'b'}, AttributeValue.fromS("ab")), + Arguments.of("Character", Character.valueOf('x'), AttributeValue.fromS("x")), + Arguments.of("Double", Double.valueOf(1.5d), AttributeValue.fromN("1.5")), + Arguments.of("Duration", Duration.ofSeconds(90), AttributeValue.fromN("90")), + Arguments.of("Float", Float.valueOf(1.5f), AttributeValue.fromN("1.5")), + Arguments.of("Instant", instant, AttributeValue.fromS("2020-01-02T03:04:05Z")), + Arguments.of("Integer", Integer.valueOf(7), AttributeValue.fromN("7")), + Arguments.of("LocalDate", LocalDate.parse("2020-01-02"), AttributeValue.fromS("2020-01-02")), + Arguments.of("LocalDateTime", LocalDateTime.parse("2020-01-02T03:04:05"), + AttributeValue.fromS("2020-01-02T03:04:05")), + Arguments.of("Locale", Locale.forLanguageTag("en-US"), AttributeValue.fromS("en-US")), + Arguments.of("LocalTime", LocalTime.parse("03:04:05"), AttributeValue.fromS("03:04:05")), + Arguments.of("Long", Long.valueOf(7L), AttributeValue.fromN("7")), + Arguments.of("MonthDay", MonthDay.of(1, 2), AttributeValue.fromS("--01-02")), + Arguments.of("OffsetDateTime", offsetDateTime, AttributeValue.fromS("2020-01-02T03:04:05+02:00")), + Arguments.of("OptionalDouble", OptionalDouble.of(1.5d), AttributeValue.fromN("1.5")), + Arguments.of("OptionalInt", OptionalInt.of(7), AttributeValue.fromN("7")), + Arguments.of("OptionalLong", OptionalLong.of(7L), AttributeValue.fromN("7")), + Arguments.of("Period", Period.of(1, 2, 3), AttributeValue.fromS("P1Y2M3D")), + Arguments.of("SdkBytes", sdkBytes, AttributeValue.fromB(sdkBytes)), + Arguments.of("Short", Short.valueOf((short) 7), AttributeValue.fromN("7")), + Arguments.of("String", "text", AttributeValue.fromS("text")), + Arguments.of("StringBuffer", new StringBuffer("text"), AttributeValue.fromS("text")), + Arguments.of("StringBuilder", new StringBuilder("text"), AttributeValue.fromS("text")), + Arguments.of("URI", URI.create("https://example.com/a"), + AttributeValue.fromS("https://example.com/a")), + Arguments.of("URL", url("https://example.com/a"), AttributeValue.fromS("https://example.com/a")), + Arguments.of("UUID", UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + AttributeValue.fromS("123e4567-e89b-12d3-a456-426614174000")), + Arguments.of("ZonedDateTime", zonedDateTime, + AttributeValue.fromS("2020-01-02T03:04:05+01:00[Europe/Paris]")), + Arguments.of("ZoneOffset", ZoneOffset.ofHours(2), AttributeValue.fromS("+02:00")), + Arguments.of("SdkNumber", SdkNumber.fromString("1.25"), AttributeValue.fromN("1.25")), + Arguments.of("TestEnum", enumConstant, AttributeValue.fromS(enumConstant.toString())) + ); + } + + private static URL url(String value) { + try { + return new URL(value); + } catch (MalformedURLException e) { + throw new IllegalStateException(e); + } + } + + enum TestEnum { + OPEN, + CLOSED + } + + static final class CustomCharSequence implements CharSequence { + private final String value; + + CustomCharSequence(String value) { + this.value = value; + } + + @Override + public int length() { + return value.length(); + } + + @Override + public char charAt(int index) { + return value.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return value.subSequence(start, end); + } + + @Override + public String toString() { + return value; + } + } + + static final class DocumentType { + } + + static final class UnsupportedType { + } + + static final class AttributeOnlyKey { + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/NestedSchemaConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/NestedSchemaConverterTest.java new file mode 100644 index 000000000000..f1d0bbaf2bce --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/NestedSchemaConverterTest.java @@ -0,0 +1,1498 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.ImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests converter resolution and item conversion for nested and flattened schema attributes. + *

+ * The tests combine bean, immutable, and static schemas with nested collections and null child values. They verify + * converter provider ownership, failure during child schema construction, flattened child conversion, and flattened + * string maps that do not require converter provider lookup. + */ +public class NestedSchemaConverterTest { + + @BeforeEach + void clearSchemaCaches() { + invokeClearSchemaCache(BeanTableSchema.class); + invokeClearSchemaCache(ImmutableTableSchema.class); + NestedNullBeanOuter.childSetterCalls.set(0); + NestedBeanOuterWithNullImmutable.childSetterCalls.set(0); + NestedImmutableOuterWithNullBean.Builder.childSetterCalls.set(0); + RecordingCustomProvider.reset(); + RecordingRequestedTypesProvider.reset(); + } + + @Test + @DisplayName("Nested bean list of unsupported type fails before an outer document converter is created") + void fromBean_whenNestedBeanWithUnsupportedList_throwsConverterNotFoundForListType() { + EnhancedType> type = EnhancedType.listOf(UnsupportedType.class); + + assertThatThrownBy(() -> TableSchema.fromBean(OuterBeanWithUnsupportedList.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Nested immutable list of Object fails converter lookup for its member type") + void fromImmutableClass_whenNestedImmutableWithObjectList_throwsConverterNotFoundForMemberType() { + assertThatThrownBy(() -> TableSchema.fromImmutableClass(OuterImmutableWithObjectList.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("Bean containing a nonnull bean round-trips nested collections") + void itemToMapThenMapToItem_whenBeanContainingNonnullBean_roundTripsNestedCollections() { + NestedBeanChild child = populatedChildBean("child-1", 7, "nested", "clicked"); + NestedBeanOuter item = populatedOuterBean("id-1", 3, "root", "opened", child); + + TableSchema schema = TableSchema.fromBean(NestedBeanOuter.class); + Map map = schema.itemToMap(item, true); + NestedBeanOuter read = schema.mapToItem(map); + + assertThat(map.get("child").hasM()).isTrue(); + assertCollectionAttributeTypes(map); + assertCollectionAttributeTypes(map.get("child").m()); + assertThat(read).isEqualTo(item); + assertReconstructedCollections(read.getCounters(), read.getLabels(), read.getEvents(), + item.getCounters(), item.getLabels(), item.getEvents()); + assertReconstructedCollections(read.getChild().getCounters(), read.getChild().getLabels(), + read.getChild().getEvents(), child.getCounters(), child.getLabels(), + child.getEvents()); + } + + @Test + @DisplayName("Bean containing a nonnull immutable round-trips nested collections") + void itemToMapThenMapToItem_whenBeanContainingNonnullImmutable_roundTripsNestedCollections() { + NestedImmutableChild child = populatedImmutableChild("child-1", 7, "nested", "clicked"); + NestedBeanOuterWithImmutable item = new NestedBeanOuterWithImmutable(); + item.setId("id-1"); + item.setCounters(counters(3)); + item.setLabels(labels("root")); + item.setEvents(events("opened")); + item.setChild(child); + + TableSchema schema = TableSchema.fromBean(NestedBeanOuterWithImmutable.class); + TableSchema.fromImmutableClass(NestedImmutableChild.class); + Map map = schema.itemToMap(item, true); + NestedBeanOuterWithImmutable read = schema.mapToItem(map); + + assertThat(map.get("child").hasM()).isTrue(); + assertCollectionAttributeTypes(map.get("child").m()); + assertReconstructedCollections(read.getChild().counters(), read.getChild().labels(), + read.getChild().events(), child.counters(), child.labels(), + child.events()); + } + + @Test + @DisplayName("Immutable containing a nonnull bean round-trips nested collections") + void itemToMapThenMapToItem_whenImmutableContainingNonnullBean_roundTripsNestedCollections() { + NestedBeanChildForImmutable child = populatedChildForImmutable("child-1", 7, "nested", "clicked"); + NestedImmutableOuter item = NestedImmutableOuter.builder() + .id("id-1") + .counters(counters(3)) + .labels(labels("root")) + .events(events("opened")) + .child(child) + .build(); + + TableSchema schema = TableSchema.fromImmutableClass(NestedImmutableOuter.class); + TableSchema.fromBean(NestedBeanChildForImmutable.class); + Map map = schema.itemToMap(item, true); + NestedImmutableOuter read = schema.mapToItem(map); + + assertThat(map.get("child").hasM()).isTrue(); + assertCollectionAttributeTypes(map.get("child").m()); + assertReconstructedCollections(read.child().getCounters(), read.child().getLabels(), + read.child().getEvents(), child.getCounters(), child.getLabels(), + child.getEvents()); + } + + @Test + @DisplayName("Bean containing a null bean writes NULL and skips the child setter") + void itemToMapThenMapToItem_whenBeanContainingNullBean_writesNulAndSkipsChildSetter() { + NestedNullBeanOuter item = new NestedNullBeanOuter(); + item.setId("id-1"); + item.setCounters(counters(3)); + item.setLabels(labels("root")); + item.setEvents(events("opened")); + NestedNullBeanOuter.childSetterCalls.set(0); + + TableSchema schema = TableSchema.fromBean(NestedNullBeanOuter.class); + Map map = schema.itemToMap(item, false); + NestedNullBeanOuter read = schema.mapToItem(map); + + assertThat(map.get("child")).isEqualTo(AttributeValue.fromNul(true)); + assertCollectionAttributeTypes(map); + assertThat(NestedNullBeanOuter.childSetterCalls.get()).isZero(); + assertThat(read.getChild()).isNull(); + assertReconstructedCollections(read.getCounters(), read.getLabels(), read.getEvents(), + item.getCounters(), item.getLabels(), item.getEvents()); + } + + @Test + @DisplayName("Bean containing a null immutable writes NULL and leaves the child null") + void itemToMapThenMapToItem_whenBeanContainingNullImmutable_writesNulAndLeavesChildNull() { + NestedBeanOuterWithNullImmutable item = new NestedBeanOuterWithNullImmutable(); + item.setId("id-1"); + item.setCounters(counters(3)); + item.setLabels(labels("root")); + item.setEvents(events("opened")); + NestedBeanOuterWithNullImmutable.childSetterCalls.set(0); + + TableSchema schema = TableSchema.fromBean(NestedBeanOuterWithNullImmutable.class); + TableSchema.fromImmutableClass(NestedNullImmutableChild.class); + Map map = schema.itemToMap(item, false); + NestedBeanOuterWithNullImmutable read = schema.mapToItem(map); + + assertThat(map.get("child")).isEqualTo(AttributeValue.fromNul(true)); + assertThat(NestedBeanOuterWithNullImmutable.childSetterCalls.get()).isZero(); + assertThat(read.getChild()).isNull(); + assertReconstructedCollections(read.getCounters(), read.getLabels(), read.getEvents(), + item.getCounters(), item.getLabels(), item.getEvents()); + } + + @Test + @DisplayName("Immutable containing a null bean writes NULL and skips the child builder method") + void itemToMapThenMapToItem_whenImmutableContainingNullBean_writesNulAndSkipsChildBuilder() { + NestedImmutableOuterWithNullBean item = NestedImmutableOuterWithNullBean.builder() + .id("id-1") + .counters(counters(3)) + .labels(labels("root")) + .events(events("opened")) + .build(); + NestedImmutableOuterWithNullBean.Builder.childSetterCalls.set(0); + + TableSchema schema = + TableSchema.fromImmutableClass(NestedImmutableOuterWithNullBean.class); + TableSchema.fromBean(NestedBeanChildForNullImmutable.class); + Map map = schema.itemToMap(item, false); + NestedImmutableOuterWithNullBean read = schema.mapToItem(map); + + assertThat(map.get("child")).isEqualTo(AttributeValue.fromNul(true)); + assertThat(NestedImmutableOuterWithNullBean.Builder.childSetterCalls.get()).isZero(); + assertThat(read.child()).isNull(); + assertReconstructedCollections(read.counters(), read.labels(), read.events(), + item.counters(), item.labels(), item.events()); + } + + @Test + @DisplayName("Flattened bean child attributes use the child's default converters") + void itemToMapThenMapToItem_whenFlattenedBeanChild_usesChildCollectionConverters() { + FlattenedChildBean child = new FlattenedChildBean(); + child.setCounters(counters(3)); + child.setLabels(labels("root")); + child.setEvents(events("opened")); + OuterFlattenedBean item = new OuterFlattenedBean(); + item.setId("id-1"); + item.setChild(child); + + TableSchema schema = TableSchema.fromBean(OuterFlattenedBean.class); + Map map = schema.itemToMap(item, true); + OuterFlattenedBean read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("child")).isNull(); + assertThat(schema.converterForAttribute("counters")).isInstanceOf(MapAttributeConverter.class); + assertThat(schema.converterForAttribute("counters").attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(schema.converterForAttribute("labels")).isInstanceOf(SetAttributeConverter.class); + assertThat(schema.converterForAttribute("labels").attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(schema.converterForAttribute("events")).isInstanceOf(ListAttributeConverter.class); + assertThat(schema.converterForAttribute("events").attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(map.get("counters").hasM()).isTrue(); + assertThat(map.get("labels").hasSs()).isTrue(); + assertThat(map.get("events").hasL()).isTrue(); + assertThat(read.getChild().getCounters()).isEqualTo(child.getCounters()).isInstanceOf(LinkedHashMap.class); + assertThat(read.getChild().getLabels()).isEqualTo(child.getLabels()).isInstanceOf(LinkedHashSet.class); + assertThat(read.getChild().getEvents()).isEqualTo(child.getEvents()).isInstanceOf(ArrayList.class); + } + + @Test + @DisplayName("Flattened bean construction fails when the child converter lookup fails") + void fromBean_whenFlattenedBeanChildWithUnsupportedType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + assertThatThrownBy(() -> TableSchema.fromBean(OuterFlattenedUnsupportedBean.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Flattened bean child uses the child's custom provider") + void itemToMapThenMapToItem_whenFlattenedBeanChildCustomProvider_selectsCustomTypeConverter() { + EnhancedType type = EnhancedType.of(CustomType.class); + FlattenedCustomChild child = new FlattenedCustomChild(); + child.setValue(new CustomType("x")); + OuterFlattenedCustomBean item = new OuterFlattenedCustomBean(); + item.setId("id-1"); + item.setChild(child); + + TableSchema schema = TableSchema.fromBean(OuterFlattenedCustomBean.class); + Map map = schema.itemToMap(item, true); + OuterFlattenedCustomBean read = schema.mapToItem(map); + + assertThat(RecordingCustomProvider.current().requestedTypes()).contains(type); + assertThat(schema.converterForAttribute("value")).isInstanceOf(CustomTypeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("custom:x"); + assertThat(read.getChild().getValue()).isEqualTo(child.getValue()); + } + + @Test + @DisplayName("Flattened string map does not consult a converter provider") + void itemToMapThenMapToItem_whenFlattenedStringMap_doesNotConsultProviderForMapType() { + EnhancedType> type = EnhancedType.mapOf(String.class, String.class); + Map attributes = new LinkedHashMap<>(); + attributes.put("attr1", "value1"); + FlattenMapBean item = new FlattenMapBean(); + item.setId("id-1"); + item.setAttributes(attributes); + + TableSchema schema = TableSchema.fromBean(FlattenMapBean.class); + Map map = schema.itemToMap(item, true); + FlattenMapBean read = schema.mapToItem(map); + + assertThat(RecordingRequestedTypesProvider.current().requestedTypes()).doesNotContain(type); + assertThat(schema.converterForAttribute("attr1")).isNull(); + assertThat(map.get("attr1").s()).isEqualTo("value1"); + assertThat(map.get("attr1").hasM()).isFalse(); + assertThat(read.getAttributes()).isEqualTo(attributes); + } + + @Test + @DisplayName("Static flattened string map does not consult a converter provider") + void itemToMapThenMapToItem_whenStaticFlattenedStringMap_doesNotConsultProviderForMapType() { + EnhancedType> type = EnhancedType.mapOf(String.class, String.class); + Map attributes = new LinkedHashMap<>(); + attributes.put("attr1", "value1"); + FlattenMapItem item = new FlattenMapItem(); + item.setId("id-1"); + item.setAttributes(attributes); + RecordingRequestedTypesProvider provider = new RecordingRequestedTypesProvider(); + + StaticTableSchema schema = StaticTableSchema.builder(FlattenMapItem.class) + .newItemSupplier(FlattenMapItem::new) + .attributeConverterProviders(provider, DefaultAttributeConverterProvider.create()) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(FlattenMapItem::getId).setter(FlattenMapItem::setId).tags(primaryPartitionKey()) + .attributeConverter(new StringAttributeConverter())) + .flatten("attributes", FlattenMapItem::getAttributes, FlattenMapItem::setAttributes) + .build(); + + Map map = schema.itemToMap(item, true); + FlattenMapItem read = schema.mapToItem(map); + + assertThat(provider.requestedTypes()).doesNotContain(type); + assertThat(schema.converterForAttribute("attr1")).isNull(); + assertThat(map.get("attr1").s()).isEqualTo("value1"); + assertThat(read.getAttributes()).isEqualTo(attributes); + } + + @Test + @DisplayName("Static flattened child schema uses the supplied schema converters") + void itemToMapThenMapToItem_whenStaticFlattenedChildSchema_usesChildCollectionConverters() { + FlattenedChildItem child = new FlattenedChildItem(); + child.setCounters(counters(3)); + child.setLabels(labels("root")); + child.setEvents(events("opened")); + FlattenedOuterItem item = new FlattenedOuterItem(); + item.setId("id-1"); + item.setChild(child); + + StaticTableSchema childSchema = StaticTableSchema.builder(FlattenedChildItem.class) + .newItemSupplier(FlattenedChildItem::new) + .addAttribute(EnhancedType.mapOf(String.class, Integer.class), a -> a.name("counters") + .getter(FlattenedChildItem::getCounters).setter(FlattenedChildItem::setCounters)) + .addAttribute(EnhancedType.setOf(String.class), a -> a.name("labels") + .getter(FlattenedChildItem::getLabels).setter(FlattenedChildItem::setLabels)) + .addAttribute(EnhancedType.listOf(String.class), a -> a.name("events") + .getter(FlattenedChildItem::getEvents).setter(FlattenedChildItem::setEvents)) + .build(); + + StaticTableSchema schema = StaticTableSchema.builder(FlattenedOuterItem.class) + .newItemSupplier(FlattenedOuterItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(FlattenedOuterItem::getId).setter(FlattenedOuterItem::setId).tags(primaryPartitionKey())) + .flatten(childSchema, FlattenedOuterItem::getChild, FlattenedOuterItem::setChild) + .build(); + + Map map = schema.itemToMap(item, true); + FlattenedOuterItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("counters")).isInstanceOf(MapAttributeConverter.class); + assertThat(schema.converterForAttribute("counters").attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(schema.converterForAttribute("labels")).isInstanceOf(SetAttributeConverter.class); + assertThat(schema.converterForAttribute("labels").attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(schema.converterForAttribute("events")).isInstanceOf(ListAttributeConverter.class); + assertThat(schema.converterForAttribute("events").attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(map.get("counters").hasM()).isTrue(); + assertThat(map.get("labels").hasSs()).isTrue(); + assertThat(map.get("events").hasL()).isTrue(); + assertThat(read.getChild().getCounters()).isEqualTo(child.getCounters()).isInstanceOf(LinkedHashMap.class); + assertThat(read.getChild().getLabels()).isEqualTo(child.getLabels()).isInstanceOf(LinkedHashSet.class); + assertThat(read.getChild().getEvents()).isEqualTo(child.getEvents()).isInstanceOf(ArrayList.class); + } + + private static void invokeClearSchemaCache(Class schemaClass) { + try { + Method method = schemaClass.getDeclaredMethod("clearSchemaCache"); + method.setAccessible(true); + method.invoke(null); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private static Map counters(int value) { + Map counters = new LinkedHashMap<>(); + counters.put("count", value); + return counters; + } + + private static Set labels(String label) { + Set labels = new LinkedHashSet<>(); + labels.add(label); + return labels; + } + + private static List events(String event) { + List events = new ArrayList<>(); + events.add(event); + return events; + } + + private static void assertCollectionAttributeTypes(Map map) { + assertThat(map.get("counters").hasM()).isTrue(); + assertThat(map.get("labels").hasSs()).isTrue(); + assertThat(map.get("events").hasL()).isTrue(); + } + + private static void assertReconstructedCollections(Map counters, Set labels, + List events, Map expectedCounters, + Set expectedLabels, List expectedEvents) { + assertThat(counters).isEqualTo(expectedCounters).isInstanceOf(LinkedHashMap.class); + assertThat(labels).isEqualTo(expectedLabels).isInstanceOf(LinkedHashSet.class); + assertThat(events).isEqualTo(expectedEvents).isInstanceOf(ArrayList.class); + } + + private static NestedBeanChild populatedChildBean(String id, int count, String label, String event) { + NestedBeanChild child = new NestedBeanChild(); + child.setId(id); + child.setCounters(counters(count)); + child.setLabels(labels(label)); + child.setEvents(events(event)); + return child; + } + + private static NestedBeanOuter populatedOuterBean(String id, int count, String label, String event, + NestedBeanChild child) { + NestedBeanOuter item = new NestedBeanOuter(); + item.setId(id); + item.setCounters(counters(count)); + item.setLabels(labels(label)); + item.setEvents(events(event)); + item.setChild(child); + return item; + } + + private static NestedImmutableChild populatedImmutableChild(String id, int count, String label, String event) { + return NestedImmutableChild.builder() + .id(id) + .counters(counters(count)) + .labels(labels(label)) + .events(events(event)) + .build(); + } + + private static NestedBeanChildForImmutable populatedChildForImmutable(String id, int count, String label, + String event) { + NestedBeanChildForImmutable child = new NestedBeanChildForImmutable(); + child.setId(id); + child.setCounters(counters(count)); + child.setLabels(labels(label)); + child.setEvents(events(event)); + return child; + } + + @DynamoDbBean + public static class OuterBeanWithUnsupportedList { + private NestedBeanWithUnsupportedList child; + + public NestedBeanWithUnsupportedList getChild() { + return child; + } + + public void setChild(NestedBeanWithUnsupportedList child) { + this.child = child; + } + } + + @DynamoDbBean + public static class NestedBeanWithUnsupportedList { + private List value; + + public List getValue() { + return value; + } + + public void setValue(List value) { + this.value = value; + } + } + + @DynamoDbImmutable(builder = OuterImmutableWithObjectList.Builder.class) + public static final class OuterImmutableWithObjectList { + private final NestedImmutableWithObjectList child; + + private OuterImmutableWithObjectList(Builder b) { + this.child = b.child; + } + + public static Builder builder() { + return new Builder(); + } + + public NestedImmutableWithObjectList child() { + return child; + } + + public static final class Builder { + private NestedImmutableWithObjectList child; + + public Builder child(NestedImmutableWithObjectList child) { + this.child = child; + return this; + } + + public OuterImmutableWithObjectList build() { + return new OuterImmutableWithObjectList(this); + } + } + } + + @DynamoDbImmutable(builder = NestedImmutableWithObjectList.Builder.class) + public static final class NestedImmutableWithObjectList { + private final List value; + + private NestedImmutableWithObjectList(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public List value() { + return value; + } + + public static final class Builder { + private List value; + + public Builder value(List value) { + this.value = value; + return this; + } + + public NestedImmutableWithObjectList build() { + return new NestedImmutableWithObjectList(this); + } + } + } + + @DynamoDbBean + public static class NestedBeanChild { + private String id; + private Map counters; + private Set labels; + private List events; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof NestedBeanChild)) { + return false; + } + NestedBeanChild that = (NestedBeanChild) o; + return Objects.equals(id, that.id) + && Objects.equals(counters, that.counters) + && Objects.equals(labels, that.labels) + && Objects.equals(events, that.events); + } + + @Override + public int hashCode() { + return Objects.hash(id, counters, labels, events); + } + } + + @DynamoDbBean + public static class NestedBeanOuter { + private String id; + private Map counters; + private Set labels; + private List events; + private NestedBeanChild child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + public NestedBeanChild getChild() { + return child; + } + + public void setChild(NestedBeanChild child) { + this.child = child; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof NestedBeanOuter)) { + return false; + } + NestedBeanOuter that = (NestedBeanOuter) o; + return Objects.equals(id, that.id) + && Objects.equals(counters, that.counters) + && Objects.equals(labels, that.labels) + && Objects.equals(events, that.events) + && Objects.equals(child, that.child); + } + + @Override + public int hashCode() { + return Objects.hash(id, counters, labels, events, child); + } + } + + @DynamoDbImmutable(builder = NestedImmutableChild.Builder.class) + public static final class NestedImmutableChild { + private final String id; + private final Map counters; + private final Set labels; + private final List events; + + private NestedImmutableChild(Builder b) { + this.id = b.id; + this.counters = b.counters; + this.labels = b.labels; + this.events = b.events; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public Map counters() { + return counters; + } + + public Set labels() { + return labels; + } + + public List events() { + return events; + } + + public static final class Builder { + private String id; + private Map counters; + private Set labels; + private List events; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder counters(Map counters) { + this.counters = counters; + return this; + } + + public Builder labels(Set labels) { + this.labels = labels; + return this; + } + + public Builder events(List events) { + this.events = events; + return this; + } + + public NestedImmutableChild build() { + return new NestedImmutableChild(this); + } + } + } + + @DynamoDbBean + public static class NestedBeanOuterWithImmutable { + private String id; + private Map counters; + private Set labels; + private List events; + private NestedImmutableChild child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + public NestedImmutableChild getChild() { + return child; + } + + public void setChild(NestedImmutableChild child) { + this.child = child; + } + } + + @DynamoDbBean + public static class NestedBeanChildForImmutable { + private String id; + private Map counters; + private Set labels; + private List events; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + } + + @DynamoDbImmutable(builder = NestedImmutableOuter.Builder.class) + public static final class NestedImmutableOuter { + private final String id; + private final Map counters; + private final Set labels; + private final List events; + private final NestedBeanChildForImmutable child; + + private NestedImmutableOuter(Builder b) { + this.id = b.id; + this.counters = b.counters; + this.labels = b.labels; + this.events = b.events; + this.child = b.child; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public Map counters() { + return counters; + } + + public Set labels() { + return labels; + } + + public List events() { + return events; + } + + public NestedBeanChildForImmutable child() { + return child; + } + + public static final class Builder { + private String id; + private Map counters; + private Set labels; + private List events; + private NestedBeanChildForImmutable child; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder counters(Map counters) { + this.counters = counters; + return this; + } + + public Builder labels(Set labels) { + this.labels = labels; + return this; + } + + public Builder events(List events) { + this.events = events; + return this; + } + + public Builder child(NestedBeanChildForImmutable child) { + this.child = child; + return this; + } + + public NestedImmutableOuter build() { + return new NestedImmutableOuter(this); + } + } + } + + @DynamoDbBean + public static class NestedNullBeanChild { + private String id; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + } + + @DynamoDbBean + public static class NestedNullBeanOuter { + static final AtomicInteger childSetterCalls = new AtomicInteger(); + private String id; + private Map counters; + private Set labels; + private List events; + private NestedNullBeanChild child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + public NestedNullBeanChild getChild() { + return child; + } + + public void setChild(NestedNullBeanChild child) { + childSetterCalls.incrementAndGet(); + this.child = child; + } + } + + @DynamoDbImmutable(builder = NestedNullImmutableChild.Builder.class) + public static final class NestedNullImmutableChild { + private final String id; + + private NestedNullImmutableChild(Builder b) { + this.id = b.id; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public static final class Builder { + private String id; + + public Builder id(String id) { + this.id = id; + return this; + } + + public NestedNullImmutableChild build() { + return new NestedNullImmutableChild(this); + } + } + } + + @DynamoDbBean + public static class NestedBeanOuterWithNullImmutable { + static final AtomicInteger childSetterCalls = new AtomicInteger(); + private String id; + private Map counters; + private Set labels; + private List events; + private NestedNullImmutableChild child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + public NestedNullImmutableChild getChild() { + return child; + } + + public void setChild(NestedNullImmutableChild child) { + childSetterCalls.incrementAndGet(); + this.child = child; + } + } + + @DynamoDbBean + public static class NestedBeanChildForNullImmutable { + private String id; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + } + + @DynamoDbImmutable(builder = NestedImmutableOuterWithNullBean.Builder.class) + public static final class NestedImmutableOuterWithNullBean { + private final String id; + private final Map counters; + private final Set labels; + private final List events; + private final NestedBeanChildForNullImmutable child; + + private NestedImmutableOuterWithNullBean(Builder b) { + this.id = b.id; + this.counters = b.counters; + this.labels = b.labels; + this.events = b.events; + this.child = b.child; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public Map counters() { + return counters; + } + + public Set labels() { + return labels; + } + + public List events() { + return events; + } + + public NestedBeanChildForNullImmutable child() { + return child; + } + + public static final class Builder { + static final AtomicInteger childSetterCalls = new AtomicInteger(); + private String id; + private Map counters; + private Set labels; + private List events; + private NestedBeanChildForNullImmutable child; + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder counters(Map counters) { + this.counters = counters; + return this; + } + + public Builder labels(Set labels) { + this.labels = labels; + return this; + } + + public Builder events(List events) { + this.events = events; + return this; + } + + public Builder child(NestedBeanChildForNullImmutable child) { + childSetterCalls.incrementAndGet(); + this.child = child; + return this; + } + + public NestedImmutableOuterWithNullBean build() { + return new NestedImmutableOuterWithNullBean(this); + } + } + } + + @DynamoDbBean + public static class FlattenedChildBean { + private Map counters; + private Set labels; + private List events; + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + } + + @DynamoDbBean + public static class OuterFlattenedBean { + private String id; + private FlattenedChildBean child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbFlatten + public FlattenedChildBean getChild() { + return child; + } + + public void setChild(FlattenedChildBean child) { + this.child = child; + } + } + + @DynamoDbBean + public static class FlattenedUnsupportedChild { + private UnsupportedType value; + + public UnsupportedType getValue() { + return value; + } + + public void setValue(UnsupportedType value) { + this.value = value; + } + } + + @DynamoDbBean + public static class OuterFlattenedUnsupportedBean { + private String id; + private FlattenedUnsupportedChild child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbFlatten + public FlattenedUnsupportedChild getChild() { + return child; + } + + public void setChild(FlattenedUnsupportedChild child) { + this.child = child; + } + } + + @DynamoDbBean(converterProviders = { + RecordingCustomProvider.class, + DefaultAttributeConverterProvider.class + }) + public static class FlattenedCustomChild { + private CustomType value; + + public CustomType getValue() { + return value; + } + + public void setValue(CustomType value) { + this.value = value; + } + } + + @DynamoDbBean + public static class OuterFlattenedCustomBean { + private String id; + private FlattenedCustomChild child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbFlatten + public FlattenedCustomChild getChild() { + return child; + } + + public void setChild(FlattenedCustomChild child) { + this.child = child; + } + } + + @DynamoDbBean(converterProviders = { + RecordingRequestedTypesProvider.class, + DefaultAttributeConverterProvider.class + }) + public static class FlattenMapBean { + private String id; + private Map attributes; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbFlatten + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + } + + static final class FlattenMapItem { + private String id; + private Map attributes; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + } + + static final class FlattenedChildItem { + private Map counters; + private Set labels; + private List events; + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + } + + static final class FlattenedOuterItem { + private String id; + private FlattenedChildItem child; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public FlattenedChildItem getChild() { + return child; + } + + public void setChild(FlattenedChildItem child) { + this.child = child; + } + } + + public static final class UnsupportedType { + } + + public static final class CustomType { + private final String value; + + public CustomType(String value) { + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CustomType)) { + return false; + } + CustomType that = (CustomType) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } + + public static final class CustomTypeConverter implements AttributeConverter { + public CustomTypeConverter() { + } + + @Override + public AttributeValue transformFrom(CustomType input) { + return AttributeValue.fromS("custom:" + input.value()); + } + + @Override + public CustomType transformTo(AttributeValue input) { + return new CustomType(input.s().substring("custom:".length())); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(CustomType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class RecordingCustomProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public RecordingCustomProvider() { + CURRENT.set(this); + } + + public static RecordingCustomProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(CustomType.class).equals(enhancedType)) { + return (AttributeConverter) new CustomTypeConverter(); + } + return null; + } + } + + public static final class RecordingRequestedTypesProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = + new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public RecordingRequestedTypesProvider() { + CURRENT.set(this); + } + + public static RecordingRequestedTypesProvider current() { + return CURRENT.get(); + } + + public static void reset() { + CURRENT.remove(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return null; + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/SecondaryIndexConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/SecondaryIndexConverterTest.java new file mode 100644 index 000000000000..f7b5be98e3e4 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/SecondaryIndexConverterTest.java @@ -0,0 +1,1040 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondaryPartitionKey; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.secondarySortKey; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.IntegerAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SdkBytesAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; + +/** + * Tests converter validation for global and local secondary index key attributes. + *

+ * The tests accept scalar string, number, and binary converter types from default providers, custom providers, and + * attribute converters. They reject nonscalar converter types and unsupported attributes during schema construction, + * while also verifying that custom conversion can make an otherwise unsupported key type scalar. + */ +public class SecondaryIndexConverterTest { + + @Test + @DisplayName("A built-in S converter is valid for a GSI partition key") + void build_whenStringGsiPartitionKey_selectsStringConverterWithTypeS() { + StaticTableSchema schema = stringGsiSchema(); + + assertThat(schema.converterForAttribute("gsiPk")).isInstanceOf(StringAttributeConverter.class); + assertThat(schema.tableMetadata().scalarAttributeType("gsiPk")).contains(ScalarAttributeType.S); + } + + @Test + @DisplayName("A built-in N converter is valid for a GSI sort key") + void build_whenIntegerGsiSortKey_selectsIntegerConverterWithTypeN() { + StaticTableSchema schema = + StaticTableSchema.builder(IntegerGsiSortItem.class) + .newItemSupplier(IntegerGsiSortItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(IntegerGsiSortItem::getPk) + .setter(IntegerGsiSortItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("gsiPk") + .getter(IntegerGsiSortItem::getGsiPk) + .setter(IntegerGsiSortItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))) + .addAttribute(Integer.class, a -> a.name("gsiSk") + .getter(IntegerGsiSortItem::getGsiSk) + .setter(IntegerGsiSortItem::setGsiSk) + .tags(secondarySortKey("gsi"))) + .build(); + + assertThat(schema.converterForAttribute("gsiSk")).isInstanceOf(IntegerAttributeConverter.class); + assertThat(schema.tableMetadata().scalarAttributeType("gsiSk")).contains(ScalarAttributeType.N); + } + + @Test + @DisplayName("A built-in B converter is valid for a GSI partition key") + void build_whenSdkBytesGsiPartitionKey_selectsSdkBytesConverterWithTypeB() { + StaticTableSchema schema = + StaticTableSchema.builder(SdkBytesGsiItem.class) + .newItemSupplier(SdkBytesGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(SdkBytesGsiItem::getPk) + .setter(SdkBytesGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(SdkBytes.class, a -> a.name("gsiPk") + .getter(SdkBytesGsiItem::getGsiPk) + .setter(SdkBytesGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))) + .build(); + + assertThat(schema.converterForAttribute("gsiPk")).isInstanceOf(SdkBytesAttributeConverter.class); + assertThat(schema.tableMetadata().scalarAttributeType("gsiPk")).contains(ScalarAttributeType.B); + } + + @Test + @DisplayName("A built-in S converter is valid for an LSI sort key") + void build_whenStringLsiSortKey_selectsStringConverterWithTypeS() { + StaticTableSchema schema = lsiSchema(); + + assertThat(schema.converterForAttribute("lsiSk")).isInstanceOf(StringAttributeConverter.class); + assertThat(schema.tableMetadata().scalarAttributeType("lsiSk")).contains(ScalarAttributeType.S); + } + + @Test + @DisplayName("A provider-selected custom S converter is valid for a GSI key") + void build_whenCustomStringConverterForGsiKey_selectsCustomConverterWithTypeS() { + RecordingCustomProvider customProvider = new RecordingCustomProvider(AttributeValueType.S); + StaticTableSchema schema = + customTypeGsiSchema(customProvider, AttributeConverterProvider.defaultProvider(), null); + + assertThat(customProvider.requestedTypes()) + .filteredOn(type -> type.equals(EnhancedType.of(CustomType.class))) + .hasSize(2); + assertThat(schema.converterForAttribute("gsiPk")).isInstanceOf(CustomTypeConverter.class); + assertThat(schema.tableMetadata().scalarAttributeType("gsiPk")).contains(ScalarAttributeType.S); + } + + @Test + @DisplayName("An attribute-level custom B converter is valid for a GSI key") + void build_whenAttributeLevelBinaryConverterForGsiKey_skipsProviderAndSelectsTypeB() { + RecordingCustomProvider schemaProvider = new RecordingCustomProvider(AttributeValueType.S); + CustomTypeConverter binaryConverter = new CustomTypeConverter(AttributeValueType.B); + StaticTableSchema schema = + customTypeGsiSchema(schemaProvider, AttributeConverterProvider.defaultProvider(), binaryConverter); + + assertThat(schemaProvider.requestedTypes()) + .filteredOn(type -> type.equals(EnhancedType.of(CustomType.class))) + .isEmpty(); + assertThat(schema.converterForAttribute("gsiPk")).isSameAs(binaryConverter); + assertThat(schema.tableMetadata().scalarAttributeType("gsiPk")).contains(ScalarAttributeType.B); + } + + @Test + @DisplayName("A BOOL converter is rejected as a GSI key") + void build_whenBooleanGsiPartitionKey_throwsUnsuitableKeyType() { + StaticTableSchema.Builder builder = + StaticTableSchema.builder(BooleanGsiItem.class) + .newItemSupplier(BooleanGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(BooleanGsiItem::getPk) + .setter(BooleanGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(Boolean.class, a -> a.name("gsiPk") + .getter(BooleanGsiItem::getGsiPk) + .setter(BooleanGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type BOOL is not a suitable type to be used as a key."); + } + + @Test + @DisplayName("An L converter is rejected as an LSI key") + void build_whenStringListLsiSortKey_throwsUnsuitableKeyType() { + StaticTableSchema.Builder builder = + StaticTableSchema.builder(ListLsiItem.class) + .newItemSupplier(ListLsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(ListLsiItem::getPk) + .setter(ListLsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.listOf(String.class), + a -> a.name("lsiSk") + .getter(ListLsiItem::getLsiSk) + .setter(ListLsiItem::setLsiSk) + .tags(secondarySortKey("lsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'lsiSk' of type L is not a suitable type to be used as a key."); + } + + @Test + @DisplayName("An M map converter is rejected as a GSI key") + void build_whenStringToIntegerMapGsiPartitionKey_throwsUnsuitableKeyType() { + StaticTableSchema.Builder builder = + StaticTableSchema.builder(MapGsiItem.class) + .newItemSupplier(MapGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(MapGsiItem::getPk) + .setter(MapGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.mapOf(String.class, Integer.class), + a -> a.name("gsiPk") + .getter(MapGsiItem::getGsiPk) + .setter(MapGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type M is not a suitable type to be used as a key."); + } + + @Test + @DisplayName("An SS converter is rejected as a GSI key") + void build_whenStringSetGsiPartitionKey_throwsUnsuitableKeyType() { + StaticTableSchema.Builder builder = + StaticTableSchema.builder(StringSetGsiItem.class) + .newItemSupplier(StringSetGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(StringSetGsiItem::getPk) + .setter(StringSetGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.setOf(String.class), + a -> a.name("gsiPk") + .getter(StringSetGsiItem::getGsiPk) + .setter(StringSetGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type SS is not a suitable type to be used as a key."); + } + + @Test + @DisplayName("An NS converter is rejected as a GSI key") + void build_whenIntegerSetGsiPartitionKey_throwsUnsuitableKeyType() { + StaticTableSchema.Builder builder = + StaticTableSchema.builder(IntegerSetGsiItem.class) + .newItemSupplier(IntegerSetGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(IntegerSetGsiItem::getPk) + .setter(IntegerSetGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.setOf(Integer.class), + a -> a.name("gsiPk") + .getter(IntegerSetGsiItem::getGsiPk) + .setter(IntegerSetGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type NS is not a suitable type to be used as a key."); + } + + @Test + @DisplayName("A BS converter is rejected as a GSI key") + void build_whenSdkBytesSetGsiPartitionKey_throwsUnsuitableKeyType() { + StaticTableSchema.Builder builder = + StaticTableSchema.builder(SdkBytesSetGsiItem.class) + .newItemSupplier(SdkBytesSetGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(SdkBytesSetGsiItem::getPk) + .setter(SdkBytesSetGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.setOf(SdkBytes.class), + a -> a.name("gsiPk") + .getter(SdkBytesSetGsiItem::getGsiPk) + .setter(SdkBytesSetGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type BS is not a suitable type to be used as a key."); + } + + @Test + @DisplayName("An M document converter is rejected as a GSI key") + void build_whenSchemaBearingDocumentGsiPartitionKey_throwsUnsuitableKeyType() { + TableSchema documentSchema = + StaticTableSchema.builder(DocumentType.class).newItemSupplier(DocumentType::new).build(); + StaticTableSchema.Builder builder = + StaticTableSchema.builder(DocumentGsiItem.class) + .newItemSupplier(DocumentGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(DocumentGsiItem::getPk) + .setter(DocumentGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.documentOf(DocumentType.class, documentSchema), + a -> a.name("gsiPk") + .getter(DocumentGsiItem::getGsiPk) + .setter(DocumentGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type M is not a suitable type to be used as a key."); + } + + @Test + @DisplayName("A provider-selected custom M converter is rejected as a GSI key") + void build_whenCustomMapConverterForGsiKey_callsProviderTwiceThenThrowsUnsuitableKeyType() { + RecordingCustomProvider customProvider = new RecordingCustomProvider(AttributeValueType.M); + StaticTableSchema.Builder builder = + customTypeGsiBuilder(customProvider, AttributeConverterProvider.defaultProvider(), null); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type M is not a suitable type to be used as a key."); + assertThat(customProvider.requestedTypes()) + .filteredOn(type -> type.equals(EnhancedType.of(CustomType.class))) + .hasSize(2); + } + + @Test + @DisplayName("An attribute-level custom M converter is rejected as a GSI key") + void build_whenAttributeLevelMapConverterForGsiKey_skipsProviderAndThrowsUnsuitableKeyType() { + ReturningNullProvider schemaProvider = new ReturningNullProvider(); + CustomTypeConverter mapConverter = new CustomTypeConverter(AttributeValueType.M); + StaticTableSchema.Builder builder = + customTypeGsiBuilder(schemaProvider, AttributeConverterProvider.defaultProvider(), mapConverter); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type M is not a suitable type to be used as a key."); + assertThat(schemaProvider.requestedTypes()).doesNotContain(EnhancedType.of(CustomType.class)); + } + + @Test + @DisplayName("An Object index attribute fails converter lookup") + void build_whenObjectGsiPartitionKey_throwsConverterNotFound() { + StaticTableSchema.Builder builder = + StaticTableSchema.builder(ObjectGsiItem.class) + .newItemSupplier(ObjectGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(ObjectGsiItem::getPk) + .setter(ObjectGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(Object.class, a -> a.name("gsiPk") + .getter(ObjectGsiItem::getGsiPk) + .setter(ObjectGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("An unsupported index attribute fails during converter selection") + void build_whenUnsupportedTypeGsiPartitionKey_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + StaticTableSchema.Builder builder = + StaticTableSchema.builder(UnsupportedGsiItem.class) + .newItemSupplier(UnsupportedGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(UnsupportedGsiItem::getPk) + .setter(UnsupportedGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(UnsupportedType.class, a -> a.name("gsiPk") + .getter(UnsupportedGsiItem::getGsiPk) + .setter(UnsupportedGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A concrete list index attribute fails during converter selection") + void build_whenConcreteArrayListGsiPartitionKey_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { }; + StaticTableSchema.Builder builder = + StaticTableSchema.builder(ArrayListGsiItem.class) + .newItemSupplier(ArrayListGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(ArrayListGsiItem::getPk) + .setter(ArrayListGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("gsiPk") + .getter(ArrayListGsiItem::getGsiPk) + .setter(ArrayListGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("A custom provider makes an Object GSI key scalar") + void build_whenObjectGsiKeyWithObjectProvider_selectsStringConverterWithTypeS() { + ObjectProvider objectProvider = new ObjectProvider(); + StaticTableSchema schema = + StaticTableSchema.builder(ObjectGsiItem.class) + .newItemSupplier(ObjectGsiItem::new) + .attributeConverterProviders(objectProvider, + AttributeConverterProvider.defaultProvider()) + .addAttribute(String.class, a -> a.name("pk") + .getter(ObjectGsiItem::getPk) + .setter(ObjectGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(Object.class, a -> a.name("gsiPk") + .getter(ObjectGsiItem::getGsiPk) + .setter(ObjectGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))) + .build(); + + assertThat(schema.converterForAttribute("gsiPk")).isInstanceOf(ObjectStringConverter.class); + assertThat(objectProvider.requestedTypes()) + .filteredOn(type -> type.equals(EnhancedType.of(Object.class))) + .hasSize(2); + assertThat(schema.tableMetadata().scalarAttributeType("gsiPk")).contains(ScalarAttributeType.S); + } + + @Test + @DisplayName("An attribute-level converter makes an Object GSI key scalar") + void build_whenObjectGsiKeyWithAttributeLevelConverter_skipsProviderAndSelectsTypeB() { + ReturningNullProvider schemaProvider = new ReturningNullProvider(); + ObjectBinaryConverter binaryConverter = new ObjectBinaryConverter(); + StaticTableSchema schema = + StaticTableSchema.builder(ObjectGsiItem.class) + .newItemSupplier(ObjectGsiItem::new) + .attributeConverterProviders(schemaProvider, + AttributeConverterProvider.defaultProvider()) + .addAttribute(String.class, a -> a.name("pk") + .getter(ObjectGsiItem::getPk) + .setter(ObjectGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(Object.class, a -> a.name("gsiPk") + .getter(ObjectGsiItem::getGsiPk) + .setter(ObjectGsiItem::setGsiPk) + .attributeConverter(binaryConverter) + .tags(secondaryPartitionKey("gsi"))) + .build(); + + assertThat(schemaProvider.requestedTypes()).doesNotContain(EnhancedType.of(Object.class)); + assertThat(schema.converterForAttribute("gsiPk")).isSameAs(binaryConverter); + assertThat(schema.tableMetadata().scalarAttributeType("gsiPk")).contains(ScalarAttributeType.B); + } + + @Test + @DisplayName("A Collection index attribute is rejected because SS is not a suitable key type") + void build_whenStringCollectionGsiPartitionKey_throwsIllegalArgumentExceptionForSsKey() { + EnhancedType> type = EnhancedType.collectionOf(String.class); + StaticTableSchema.Builder builder = + StaticTableSchema.builder(CollectionGsiItem.class) + .newItemSupplier(CollectionGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(CollectionGsiItem::getPk) + .setter(CollectionGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(type, + a -> a.name("gsiPk") + .getter(CollectionGsiItem::getGsiPk) + .setter(CollectionGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type SS is not a suitable type to be used as a key."); + } + + @Test + @DisplayName("An Iterable index attribute is rejected because SS is not a suitable key type") + void build_whenStringIterableGsiPartitionKey_throwsIllegalArgumentExceptionForSsKey() { + EnhancedType> type = new EnhancedType>() { }; + StaticTableSchema.Builder builder = + StaticTableSchema.builder(IterableGsiItem.class) + .newItemSupplier(IterableGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(IterableGsiItem::getPk) + .setter(IterableGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(type, + a -> a.name("gsiPk") + .getter(IterableGsiItem::getGsiPk) + .setter(IterableGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute 'gsiPk' of type SS is not a suitable type to be used as a key."); + } + + private static StaticTableSchema stringGsiSchema() { + return StaticTableSchema.builder(StringGsiItem.class) + .newItemSupplier(StringGsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(StringGsiItem::getPk) + .setter(StringGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("gsiPk") + .getter(StringGsiItem::getGsiPk) + .setter(StringGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi"))) + .build(); + } + + private static StaticTableSchema lsiSchema() { + return StaticTableSchema.builder(LsiItem.class) + .newItemSupplier(LsiItem::new) + .addAttribute(String.class, a -> a.name("pk") + .getter(LsiItem::getPk) + .setter(LsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(String.class, a -> a.name("lsiSk") + .getter(LsiItem::getLsiSk) + .setter(LsiItem::setLsiSk) + .tags(secondarySortKey("lsi"))) + .build(); + } + + private static StaticTableSchema customTypeGsiSchema( + AttributeConverterProvider first, + AttributeConverterProvider second, + AttributeConverter attributeConverter) { + return customTypeGsiBuilder(first, second, attributeConverter).build(); + } + + private static StaticTableSchema.Builder customTypeGsiBuilder( + AttributeConverterProvider first, + AttributeConverterProvider second, + AttributeConverter attributeConverter) { + return StaticTableSchema.builder(CustomTypeGsiItem.class) + .newItemSupplier(CustomTypeGsiItem::new) + .attributeConverterProviders(first, second) + .addAttribute(String.class, a -> a.name("pk") + .getter(CustomTypeGsiItem::getPk) + .setter(CustomTypeGsiItem::setPk) + .tags(primaryPartitionKey())) + .addAttribute(CustomType.class, a -> { + a.name("gsiPk") + .getter(CustomTypeGsiItem::getGsiPk) + .setter(CustomTypeGsiItem::setGsiPk) + .tags(secondaryPartitionKey("gsi")); + if (attributeConverter != null) { + a.attributeConverter(attributeConverter); + } + }); + } + + static final class StringGsiItem { + private String pk; + private String gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public String getGsiPk() { + return gsiPk; + } + + public void setGsiPk(String gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class IntegerGsiSortItem { + private String pk; + private String gsiPk; + private Integer gsiSk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public String getGsiPk() { + return gsiPk; + } + + public void setGsiPk(String gsiPk) { + this.gsiPk = gsiPk; + } + + public Integer getGsiSk() { + return gsiSk; + } + + public void setGsiSk(Integer gsiSk) { + this.gsiSk = gsiSk; + } + } + + static final class SdkBytesGsiItem { + private String pk; + private SdkBytes gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public SdkBytes getGsiPk() { + return gsiPk; + } + + public void setGsiPk(SdkBytes gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class LsiItem { + private String pk; + private String lsiSk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public String getLsiSk() { + return lsiSk; + } + + public void setLsiSk(String lsiSk) { + this.lsiSk = lsiSk; + } + } + + static final class CustomTypeGsiItem { + private String pk; + private CustomType gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public CustomType getGsiPk() { + return gsiPk; + } + + public void setGsiPk(CustomType gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class BooleanGsiItem { + private String pk; + private Boolean gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public Boolean getGsiPk() { + return gsiPk; + } + + public void setGsiPk(Boolean gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class ListLsiItem { + private String pk; + private List lsiSk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public List getLsiSk() { + return lsiSk; + } + + public void setLsiSk(List lsiSk) { + this.lsiSk = lsiSk; + } + } + + static final class MapGsiItem { + private String pk; + private Map gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public Map getGsiPk() { + return gsiPk; + } + + public void setGsiPk(Map gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class StringSetGsiItem { + private String pk; + private Set gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public Set getGsiPk() { + return gsiPk; + } + + public void setGsiPk(Set gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class IntegerSetGsiItem { + private String pk; + private Set gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public Set getGsiPk() { + return gsiPk; + } + + public void setGsiPk(Set gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class SdkBytesSetGsiItem { + private String pk; + private Set gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public Set getGsiPk() { + return gsiPk; + } + + public void setGsiPk(Set gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class DocumentGsiItem { + private String pk; + private DocumentType gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public DocumentType getGsiPk() { + return gsiPk; + } + + public void setGsiPk(DocumentType gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class ObjectGsiItem { + private String pk; + private Object gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public Object getGsiPk() { + return gsiPk; + } + + public void setGsiPk(Object gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class UnsupportedGsiItem { + private String pk; + private UnsupportedType gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public UnsupportedType getGsiPk() { + return gsiPk; + } + + public void setGsiPk(UnsupportedType gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class ArrayListGsiItem { + private String pk; + private ArrayList gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public ArrayList getGsiPk() { + return gsiPk; + } + + public void setGsiPk(ArrayList gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class CollectionGsiItem { + private String pk; + private Collection gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public Collection getGsiPk() { + return gsiPk; + } + + public void setGsiPk(Collection gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class IterableGsiItem { + private String pk; + private Iterable gsiPk; + + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + public Iterable getGsiPk() { + return gsiPk; + } + + public void setGsiPk(Iterable gsiPk) { + this.gsiPk = gsiPk; + } + } + + static final class CustomType { + } + + static final class UnsupportedType { + } + + static final class DocumentType { + } + + static final class CustomTypeConverter implements AttributeConverter { + private final AttributeValueType attributeValueType; + + CustomTypeConverter(AttributeValueType attributeValueType) { + this.attributeValueType = attributeValueType; + } + + @Override + public AttributeValue transformFrom(CustomType input) { + if (attributeValueType == AttributeValueType.M) { + return AttributeValue.fromM(Collections.singletonMap("v", AttributeValue.fromS("x"))); + } + if (attributeValueType == AttributeValueType.B) { + return AttributeValue.fromB(SdkBytes.fromUtf8String("x")); + } + return AttributeValue.fromS("custom:x"); + } + + @Override + public CustomType transformTo(AttributeValue input) { + return new CustomType(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(CustomType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return attributeValueType; + } + } + + static final class RecordingCustomProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + private final CustomTypeConverter converter; + + RecordingCustomProvider(AttributeValueType attributeValueType) { + this.converter = new CustomTypeConverter(attributeValueType); + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(CustomType.class).equals(enhancedType)) { + return (AttributeConverter) converter; + } + return null; + } + + List> requestedTypes() { + return requestedTypes; + } + } + + static final class ObjectStringConverter implements AttributeConverter { + @Override + public AttributeValue transformFrom(Object input) { + return AttributeValue.fromS(String.valueOf(input)); + } + + @Override + public Object transformTo(AttributeValue input) { + return input.s(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(Object.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + static final class ObjectBinaryConverter implements AttributeConverter { + @Override + public AttributeValue transformFrom(Object input) { + return AttributeValue.fromB(SdkBytes.fromUtf8String(String.valueOf(input))); + } + + @Override + public Object transformTo(AttributeValue input) { + return input.b().asUtf8String(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(Object.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.B; + } + } + + static final class ObjectProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + private final ObjectStringConverter converter = new ObjectStringConverter(); + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(Object.class).equals(enhancedType)) { + return (AttributeConverter) converter; + } + return null; + } + + List> requestedTypes() { + return requestedTypes; + } + } + + static final class ReturningNullProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return null; + } + + List> requestedTypes() { + return requestedTypes; + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/StaticImmutableSchemaConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/StaticImmutableSchemaConverterTest.java new file mode 100644 index 000000000000..514bb8189fa7 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/StaticImmutableSchemaConverterTest.java @@ -0,0 +1,1606 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DocumentAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.InstantAsStringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests converter selection and item conversion for static immutable table schemas. + *

+ * The tests cover scalar, enumeration, collection, map, and schema backed document attributes. They also verify null + * handling, configured provider precedence, attribute converters, empty provider lists, recursive member lookup, and + * unsupported attribute declarations during schema construction. + */ +public class StaticImmutableSchemaConverterTest { + + @Test + @DisplayName("Instant attribute selects InstantAsStringAttributeConverter and round-trips the input") + void converterForAttribute_whenInstantValue_selectsInstantConverterAndRebuildsEqualValue() { + Instant input = Instant.parse("2020-01-02T03:04:05Z"); + InstantItem item = InstantItem.builder().id("id-1").value(input).build(); + EnhancedType type = EnhancedType.of(Instant.class); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(InstantItem.class, InstantItem.Builder.class) + .newItemBuilder(InstantItem.Builder::new, InstantItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(InstantItem::id).setter(InstantItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(InstantItem::value).setter(InstantItem.Builder::value)) + .build(); + + Map map = schema.itemToMap(item, true); + InstantItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(InstantAsStringAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.value()).isEqualTo(input); + } + + @Test + @DisplayName("List of String selects ListAttributeConverter and rebuilds an equal ArrayList") + void converterForAttribute_whenStringList_selectsListConverterAndRebuildsArrayList() { + List input = new ArrayList<>(Arrays.asList("a", "a", "b")); + ListItem item = ListItem.builder().id("id-1").value(input).build(); + EnhancedType> type = EnhancedType.listOf(String.class); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(ListItem.class, ListItem.Builder.class) + .newItemBuilder(ListItem.Builder::new, ListItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(ListItem::id).setter(ListItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(ListItem::value).setter(ListItem.Builder::value)) + .build(); + + Map map = schema.itemToMap(item, true); + ListItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(ListAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(read.value()).isEqualTo(input).isInstanceOf(ArrayList.class); + } + + @Test + @DisplayName("Schema-bearing document attribute selects DocumentAttributeConverter and reconstructs") + void converterForAttribute_whenDocumentType_selectsDocumentConverterAndReconstructs() { + TableSchema documentSchema = documentSchema(); + DocumentType input = new DocumentType(); + input.setName("doc"); + DocumentItem item = DocumentItem.builder().id("id-1").value(input).build(); + EnhancedType type = EnhancedType.documentOf(DocumentType.class, documentSchema); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(DocumentItem.class, DocumentItem.Builder.class) + .newItemBuilder(DocumentItem.Builder::new, DocumentItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(DocumentItem::id).setter(DocumentItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(DocumentItem::value).setter(DocumentItem.Builder::value)) + .build(); + + Map map = schema.itemToMap(item, true); + DocumentItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(DocumentAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(map.get("value").m()).containsEntry("name", AttributeValue.fromS("doc")); + assertThat(read.value().getName()).isEqualTo("doc"); + } + + @Test + @DisplayName("Null string is stored as DynamoDB NULL when ignore-nulls is false") + void itemToMap_whenNullStringIgnoreNullsFalse_containsNulValue() { + StringItem item = StringItem.builder().id("id-1").build(); + + Map map = stringSchema().itemToMap(item, false); + + assertThat(map).containsEntry("value", AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("Null string is omitted when ignore-nulls is true") + void itemToMap_whenNullStringIgnoreNullsTrue_omitsValue() { + StringItem item = StringItem.builder().id("id-1").build(); + + Map map = stringSchema().itemToMap(item, true); + + assertThat(map).doesNotContainKey("value"); + } + + @Test + @DisplayName("DynamoDB NULL is skipped on read and leaves the builder method uncalled") + void mapToItem_whenNulValue_doesNotCallBuilderMethodAndLeavesValueNull() { + AtomicInteger valueCalls = new AtomicInteger(); + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(StringItem.class, StringItem.Builder.class) + .newItemBuilder(StringItem.Builder::new, StringItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(StringItem::id).setter(StringItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(EnhancedType.of(String.class), a -> a.name("value") + .getter(StringItem::value) + .setter((builder, value) -> { + valueCalls.incrementAndGet(); + builder.value(value); + })) + .build(); + Map map = new LinkedHashMap<>(); + map.put("id", AttributeValue.fromS("id-1")); + map.put("value", AttributeValue.fromNul(true)); + + StringItem read = schema.mapToItem(map); + + assertThat(read).isNotNull(); + assertThat(valueCalls.get()).isZero(); + assertThat(read.value()).isNull(); + } + + @Test + @DisplayName("Unconverted Object attribute fails converter lookup") + void build_whenUnconvertedObject_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(Object.class); + + assertThatThrownBy(() -> StaticImmutableTableSchema.builder(ObjectItem.class, ObjectItem.Builder.class) + .newItemBuilder(ObjectItem.Builder::new, ObjectItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(ObjectItem::id).setter(ObjectItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(ObjectItem::value).setter(ObjectItem.Builder::value)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Unsupported attribute fails lookup with the enclosing type in the message") + void build_whenUnsupportedType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + assertThatThrownBy(() -> + StaticImmutableTableSchema.builder(UnsupportedItem.class, UnsupportedItem.Builder.class) + .newItemBuilder(UnsupportedItem.Builder::new, UnsupportedItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(UnsupportedItem::id).setter(UnsupportedItem.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(UnsupportedItem::value).setter(UnsupportedItem.Builder::value)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Custom provider before default selects CustomTypeConverter") + void build_whenRecordingCustomBeforeDefault_selectsCustomTypeConverter() { + CustomType input = new CustomType("x"); + CustomTypeItem item = CustomTypeItem.builder().value(input).build(); + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingCustomProvider custom = new RecordingCustomProvider(); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(CustomTypeItem.class, CustomTypeItem.Builder.class) + .newItemBuilder(CustomTypeItem.Builder::new, CustomTypeItem.Builder::build) + .attributeConverterProviders(custom, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(CustomTypeItem::value).setter(CustomTypeItem.Builder::value) + .tags(primaryPartitionKey())) + .build(); + + Map map = schema.itemToMap(item, true); + CustomTypeItem read = schema.mapToItem(map); + + assertThat(custom.requestedTypes()).hasSize(2); + assertThat(schema.converterForAttribute("value")).isInstanceOf(CustomTypeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.value()).isEqualTo(input); + } + + @Test + @DisplayName("Null-returning provider falls back to the default string converter") + void build_whenReturningNullBeforeDefault_selectsStringAttributeConverter() { + EnhancedType type = EnhancedType.of(String.class); + ReturningNullProvider custom = new ReturningNullProvider(); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(StringItem.class, StringItem.Builder.class) + .newItemBuilder(StringItem.Builder::new, StringItem.Builder::build) + .attributeConverterProviders(custom, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(StringItem::value).setter(StringItem.Builder::value).tags(primaryPartitionKey())) + .build(); + + assertThat(custom.requestedTypes()).hasSize(1); + assertThat(schema.converterForAttribute("value")).isInstanceOf(StringAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + } + + @Test + @DisplayName("Default provider before custom blocks later custom fallback") + void build_whenDefaultBeforeRecordingCustom_throwsAndDoesNotInvokeCustom() { + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingCustomProvider custom = new RecordingCustomProvider(); + + assertThatThrownBy(() -> + StaticImmutableTableSchema.builder(CustomTypeItem.class, CustomTypeItem.Builder.class) + .newItemBuilder(CustomTypeItem.Builder::new, CustomTypeItem.Builder::build) + .attributeConverterProviders(DefaultAttributeConverterProvider.create(), custom) + .addAttribute(type, a -> a.name("value") + .getter(CustomTypeItem::value).setter(CustomTypeItem.Builder::value) + .tags(primaryPartitionKey())) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(custom.requestedTypes()).isEmpty(); + } + + @Test + @DisplayName("Throwing provider exception is propagated without consulting the default") + void build_whenThrowingProviderBeforeDefault_propagatesProviderFailure() { + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingDefaultProvider defaultProvider = new RecordingDefaultProvider(); + + assertThatThrownBy(() -> + StaticImmutableTableSchema.builder(CustomTypeItem.class, CustomTypeItem.Builder.class) + .newItemBuilder(CustomTypeItem.Builder::new, CustomTypeItem.Builder::build) + .attributeConverterProviders(new ThrowingProvider(), defaultProvider) + .addAttribute(type, a -> a.name("value") + .getter(CustomTypeItem::value).setter(CustomTypeItem.Builder::value) + .tags(primaryPartitionKey())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute converter provider failed while looking up " + type); + assertThat(defaultProvider.requestedTypes()).isEmpty(); + } + + @Test + @DisplayName("Static immutable ObjectProvider before default selects ObjectStringConverter") + void build_whenObjectProviderBeforeDefault_selectsObjectStringConverter() { + ObjectItem item = ObjectItem.builder().value(new Object()).build(); + EnhancedType type = EnhancedType.of(Object.class); + ObjectProvider provider = new ObjectProvider(); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(ObjectItem.class, ObjectItem.Builder.class) + .newItemBuilder(ObjectItem.Builder::new, ObjectItem.Builder::build) + .attributeConverterProviders(provider, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(ObjectItem::value).setter(ObjectItem.Builder::value) + .tags(primaryPartitionKey())) + .build(); + + Map map = schema.itemToMap(item, true); + ObjectItem read = schema.mapToItem(map); + + int objectRequestCount = 0; + for (EnhancedType requestedType : provider.requestedTypes()) { + if (EnhancedType.of(Object.class).equals(requestedType)) { + objectRequestCount++; + } + } + assertThat(objectRequestCount).isEqualTo(2); + assertThat(schema.converterForAttribute("value")).isInstanceOf(ObjectStringConverter.class); + assertThat(read.value()).isEqualTo("custom"); + } + + @Test + @DisplayName("Attribute-level ObjectStringConverter intercepts Object without consulting the provider") + void converterForAttribute_whenObjectStringConverter_skipsProviderAndBuilderReceivesConverterValue() { + ObjectItem item = ObjectItem.builder().value(new Object()).build(); + EnhancedType type = EnhancedType.of(Object.class); + ObjectProvider provider = new ObjectProvider(); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(ObjectItem.class, ObjectItem.Builder.class) + .newItemBuilder(ObjectItem.Builder::new, ObjectItem.Builder::build) + .attributeConverterProviders(provider, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(ObjectItem::value).setter(ObjectItem.Builder::value).tags(primaryPartitionKey()) + .attributeConverter(new ObjectStringConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + ObjectItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(ObjectStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(provider.requestedTypes()).doesNotContain(type); + assertThat(read.value()).isEqualTo("custom"); + } + + @Test + @DisplayName("Attribute-level UnsupportedStringConverter intercepts an unsupported class") + void converterForAttribute_whenUnsupportedStringConverter_rebuildsEqualValue() { + UnsupportedType input = new UnsupportedType(); + UnsupportedItem item = UnsupportedItem.builder().value(input).build(); + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(UnsupportedItem.class, UnsupportedItem.Builder.class) + .newItemBuilder(UnsupportedItem.Builder::new, UnsupportedItem.Builder::build) + .addAttribute(type, a -> a.name("value") + .getter(UnsupportedItem::value).setter(UnsupportedItem.Builder::value) + .tags(primaryPartitionKey()) + .attributeConverter(new UnsupportedStringConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + UnsupportedItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(UnsupportedStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.value()).isEqualTo(input); + } + + @Test + @DisplayName("Attribute-level LinkedListConverter intercepts a captured LinkedList type") + void converterForAttribute_whenLinkedListConverter_builderReceivesLinkedList() { + LinkedList input = new LinkedList<>(Arrays.asList("a", "b")); + LinkedListItem item = LinkedListItem.builder().id("id-1").value(input).build(); + EnhancedType> type = new EnhancedType>() { + }; + AtomicReference assigned = new AtomicReference<>(); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(LinkedListItem.class, LinkedListItem.Builder.class) + .newItemBuilder(LinkedListItem.Builder::new, LinkedListItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(LinkedListItem::id).setter(LinkedListItem.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(LinkedListItem::value) + .setter((builder, value) -> { + assigned.set(value); + builder.value(value); + }) + .attributeConverter(new LinkedListConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(LinkedListConverter.class); + assertThat(schema.converterForAttribute("value").type()).isEqualTo(type); + assertThat(assigned.get()).isInstanceOf(LinkedList.class); + } + + @Test + @DisplayName("Empty providers fail without an attribute converter") + void build_whenEmptyProvidersWithoutAttributeConverter_throwsNullPointerException() { + EnhancedType type = EnhancedType.of(String.class); + + assertThatThrownBy(() -> StaticImmutableTableSchema.builder(StringItem.class, StringItem.Builder.class) + .newItemBuilder(StringItem.Builder::new, StringItem.Builder::build) + .attributeConverterProviders(Collections.emptyList()) + .addAttribute(type, a -> a.name("value") + .getter(StringItem::value).setter(StringItem.Builder::value).tags(primaryPartitionKey())) + .build()) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("Attribute-level CustomStringConverter survives empty providers") + void itemToMap_whenEmptyProvidersWithCustomStringConverter_writesCustomText() { + StringItem item = StringItem.builder().value("text").build(); + EnhancedType type = EnhancedType.of(String.class); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(StringItem.class, StringItem.Builder.class) + .newItemBuilder(StringItem.Builder::new, StringItem.Builder::build) + .attributeConverterProviders(Collections.emptyList()) + .addAttribute(type, a -> a.name("value") + .getter(StringItem::value).setter(StringItem.Builder::value).tags(primaryPartitionKey()) + .attributeConverter(new CustomStringConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + StringItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(CustomStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("custom:text"); + assertThat(read.value()).isEqualTo("text"); + } + + @Test + @DisplayName("Unconverted captured LinkedList fails lookup before assignment") + void build_whenUnconvertedLinkedList_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> + StaticImmutableTableSchema.builder(LinkedListItem.class, LinkedListItem.Builder.class) + .newItemBuilder(LinkedListItem.Builder::new, LinkedListItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(LinkedListItem::id).setter(LinkedListItem.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(LinkedListItem::value).setter(LinkedListItem.Builder::value)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Custom member converter is not consulted for an enclosing list token") + void build_whenListOfUnsupportedWithMemberProvider_throwsConverterNotFoundForListType() { + EnhancedType> type = EnhancedType.listOf(UnsupportedType.class); + UnsupportedMemberProvider custom = new UnsupportedMemberProvider(); + + assertThatThrownBy(() -> + StaticImmutableTableSchema.builder(UnsupportedListItem.class, UnsupportedListItem.Builder.class) + .newItemBuilder(UnsupportedListItem.Builder::new, UnsupportedListItem.Builder::build) + .attributeConverterProviders(custom, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(UnsupportedListItem::value).setter(UnsupportedListItem.Builder::value)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(custom.requestedTypes()).containsExactly(type); + } + + @Test + @DisplayName("Collection of String selects SetAttributeConverter and rebuilds a LinkedHashSet") + void converterForAttribute_whenStringCollection_selectsSetConverterAndRebuildsLinkedHashSet() { + Collection input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + CollectionItem item = CollectionItem.builder().id("id-1").value(input).build(); + EnhancedType> type = EnhancedType.collectionOf(String.class); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(CollectionItem.class, CollectionItem.Builder.class) + .newItemBuilder(CollectionItem.Builder::new, CollectionItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(CollectionItem::id).setter(CollectionItem.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(CollectionItem::value).setter(CollectionItem.Builder::value)) + .build(); + + Map map = schema.itemToMap(item, true); + CollectionItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(SetAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.value()).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("Set of String selects SetAttributeConverter and rebuilds a LinkedHashSet") + void converterForAttribute_whenStringSet_selectsSetConverterAndRebuildsLinkedHashSet() { + Set input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + SetItem item = SetItem.builder().id("id-1").value(input).build(); + EnhancedType> type = EnhancedType.setOf(String.class); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(SetItem.class, SetItem.Builder.class) + .newItemBuilder(SetItem.Builder::new, SetItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(SetItem::id).setter(SetItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(SetItem::value).setter(SetItem.Builder::value)) + .build(); + + Map map = schema.itemToMap(item, true); + SetItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(SetAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.value()).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("Map of String to Integer selects MapAttributeConverter and rebuilds a LinkedHashMap") + void converterForAttribute_whenStringIntegerMap_selectsMapConverterAndRebuildsLinkedHashMap() { + Map input = new LinkedHashMap<>(); + input.put("a", 1); + input.put("b", 2); + MapItem item = MapItem.builder().id("id-1").value(input).build(); + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(MapItem.class, MapItem.Builder.class) + .newItemBuilder(MapItem.Builder::new, MapItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(MapItem::id).setter(MapItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(MapItem::value).setter(MapItem.Builder::value)) + .build(); + + Map map = schema.itemToMap(item, true); + MapItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(MapAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(read.value()).isEqualTo(input).isInstanceOf(LinkedHashMap.class); + } + + @Test + @DisplayName("Enum attribute selects EnumAttributeConverter and round-trips") + void converterForAttribute_whenEnumValue_selectsEnumConverterAndReadsEqualValue() { + EnumItem item = EnumItem.builder().id("id-1").value(TestEnum.OPEN).build(); + EnhancedType type = EnhancedType.of(TestEnum.class); + + StaticImmutableTableSchema schema = + StaticImmutableTableSchema.builder(EnumItem.class, EnumItem.Builder.class) + .newItemBuilder(EnumItem.Builder::new, EnumItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(EnumItem::id).setter(EnumItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(EnumItem::value).setter(EnumItem.Builder::value)) + .build(); + + Map map = schema.itemToMap(item, true); + EnumItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(EnumAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("OPEN"); + assertThat(read.value()).isEqualTo(TestEnum.OPEN); + } + + @Test + @DisplayName("Unconverted captured HashSet fails lookup before assignment") + void build_whenUnconvertedHashSet_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> StaticImmutableTableSchema.builder(HashSetItem.class, HashSetItem.Builder.class) + .newItemBuilder(HashSetItem.Builder::new, HashSetItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(HashSetItem::id).setter(HashSetItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(HashSetItem::value).setter(HashSetItem.Builder::value)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Unconverted captured HashMap fails lookup before assignment") + void build_whenUnconvertedHashMap_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> StaticImmutableTableSchema.builder(HashMapItem.class, HashMapItem.Builder.class) + .newItemBuilder(HashMapItem.Builder::new, HashMapItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(HashMapItem::id).setter(HashMapItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(HashMapItem::value).setter(HashMapItem.Builder::value)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + private static TableSchema documentSchema() { + return StaticTableSchema.builder(DocumentType.class) + .newItemSupplier(DocumentType::new) + .addAttribute(String.class, a -> a.name("name") + .getter(DocumentType::getName) + .setter(DocumentType::setName)) + .build(); + } + + private static StaticImmutableTableSchema stringSchema() { + return StaticImmutableTableSchema.builder(StringItem.class, StringItem.Builder.class) + .newItemBuilder(StringItem.Builder::new, StringItem.Builder::build) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(StringItem::id).setter(StringItem.Builder::id).tags(primaryPartitionKey())) + .addAttribute(EnhancedType.of(String.class), a -> a.name("value") + .getter(StringItem::value).setter(StringItem.Builder::value)) + .build(); + } + + static final class InstantItem { + private final String id; + private final Instant value; + + private InstantItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public Instant value() { + return value; + } + + public static final class Builder { + private String id; + private Instant value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(Instant value) { + this.value = value; + return this; + } + + public InstantItem build() { + return new InstantItem(this); + } + } + } + + static final class ListItem { + private final String id; + private final List value; + + private ListItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public List value() { + return value; + } + + public static final class Builder { + private String id; + private List value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(List value) { + this.value = value; + return this; + } + + public ListItem build() { + return new ListItem(this); + } + } + } + + static final class DocumentItem { + private final String id; + private final DocumentType value; + + private DocumentItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public DocumentType value() { + return value; + } + + public static final class Builder { + private String id; + private DocumentType value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(DocumentType value) { + this.value = value; + return this; + } + + public DocumentItem build() { + return new DocumentItem(this); + } + } + } + + static final class StringItem { + private final String id; + private final String value; + + private StringItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public String value() { + return value; + } + + public static final class Builder { + private String id; + private String value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(String value) { + this.value = value; + return this; + } + + public StringItem build() { + return new StringItem(this); + } + } + } + + static final class ObjectItem { + private final String id; + private final Object value; + + private ObjectItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public Object value() { + return value; + } + + public static final class Builder { + private String id; + private Object value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(Object value) { + this.value = value; + return this; + } + + public ObjectItem build() { + return new ObjectItem(this); + } + } + } + + static final class UnsupportedItem { + private final String id; + private final UnsupportedType value; + + private UnsupportedItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public UnsupportedType value() { + return value; + } + + public static final class Builder { + private String id; + private UnsupportedType value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(UnsupportedType value) { + this.value = value; + return this; + } + + public UnsupportedItem build() { + return new UnsupportedItem(this); + } + } + } + + static final class CustomTypeItem { + private final CustomType value; + + private CustomTypeItem(Builder b) { + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public CustomType value() { + return value; + } + + public static final class Builder { + private CustomType value; + + public Builder() { + } + + public Builder value(CustomType value) { + this.value = value; + return this; + } + + public CustomTypeItem build() { + return new CustomTypeItem(this); + } + } + } + + static final class LinkedListItem { + private final String id; + private final LinkedList value; + + private LinkedListItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public LinkedList value() { + return value; + } + + public static final class Builder { + private String id; + private LinkedList value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(LinkedList value) { + this.value = value; + return this; + } + + public LinkedListItem build() { + return new LinkedListItem(this); + } + } + } + + static final class UnsupportedListItem { + private final String id; + private final List value; + + private UnsupportedListItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public List value() { + return value; + } + + public static final class Builder { + private String id; + private List value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(List value) { + this.value = value; + return this; + } + + public UnsupportedListItem build() { + return new UnsupportedListItem(this); + } + } + } + + static final class CollectionItem { + private final String id; + private final Collection value; + + private CollectionItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public Collection value() { + return value; + } + + public static final class Builder { + private String id; + private Collection value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(Collection value) { + this.value = value; + return this; + } + + public CollectionItem build() { + return new CollectionItem(this); + } + } + } + + static final class SetItem { + private final String id; + private final Set value; + + private SetItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public Set value() { + return value; + } + + public static final class Builder { + private String id; + private Set value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(Set value) { + this.value = value; + return this; + } + + public SetItem build() { + return new SetItem(this); + } + } + } + + static final class MapItem { + private final String id; + private final Map value; + + private MapItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public Map value() { + return value; + } + + public static final class Builder { + private String id; + private Map value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(Map value) { + this.value = value; + return this; + } + + public MapItem build() { + return new MapItem(this); + } + } + } + + static final class EnumItem { + private final String id; + private final TestEnum value; + + private EnumItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public TestEnum value() { + return value; + } + + public static final class Builder { + private String id; + private TestEnum value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(TestEnum value) { + this.value = value; + return this; + } + + public EnumItem build() { + return new EnumItem(this); + } + } + } + + static final class HashSetItem { + private final String id; + private final HashSet value; + + private HashSetItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public HashSet value() { + return value; + } + + public static final class Builder { + private String id; + private HashSet value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(HashSet value) { + this.value = value; + return this; + } + + public HashSetItem build() { + return new HashSetItem(this); + } + } + } + + static final class HashMapItem { + private final String id; + private final HashMap value; + + private HashMapItem(Builder b) { + this.id = b.id; + this.value = b.value; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public HashMap value() { + return value; + } + + public static final class Builder { + private String id; + private HashMap value; + + public Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder value(HashMap value) { + this.value = value; + return this; + } + + public HashMapItem build() { + return new HashMapItem(this); + } + } + } + + enum TestEnum { + OPEN, + CLOSED + } + + static final class CustomType { + private final String value; + + CustomType(String value) { + this.value = value; + } + + String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CustomType)) { + return false; + } + CustomType that = (CustomType) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } + + static final class UnsupportedType { + @Override + public boolean equals(Object o) { + return o instanceof UnsupportedType; + } + + @Override + public int hashCode() { + return 1; + } + } + + static final class DocumentType { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + public static final class RecordingCustomProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public RecordingCustomProvider() { + CURRENT.set(this); + } + + public static RecordingCustomProvider current() { + return CURRENT.get(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(CustomType.class).equals(enhancedType)) { + return (AttributeConverter) new CustomTypeConverter(); + } + return null; + } + } + + public static final class ReturningNullProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public ReturningNullProvider() { + CURRENT.set(this); + } + + public static ReturningNullProvider current() { + return CURRENT.get(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return null; + } + } + + public static final class ThrowingProvider implements AttributeConverterProvider { + public ThrowingProvider() { + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + throw new IllegalArgumentException("Attribute converter provider failed while looking up " + enhancedType); + } + } + + public static final class ObjectProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public ObjectProvider() { + CURRENT.set(this); + } + + public static ObjectProvider current() { + return CURRENT.get(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(Object.class).equals(enhancedType)) { + return (AttributeConverter) new ObjectStringConverter(); + } + return null; + } + } + + static final class RecordingDefaultProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + private final AttributeConverterProvider delegate = DefaultAttributeConverterProvider.create(); + + List> requestedTypes() { + return requestedTypes; + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return delegate.converterFor(enhancedType); + } + } + + static final class UnsupportedMemberProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + + List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(UnsupportedType.class).equals(enhancedType)) { + return (AttributeConverter) new UnsupportedStringConverter(); + } + return null; + } + } + + public static final class CustomTypeConverter implements AttributeConverter { + public CustomTypeConverter() { + } + + @Override + public AttributeValue transformFrom(CustomType input) { + return AttributeValue.fromS("custom:" + input.value()); + } + + @Override + public CustomType transformTo(AttributeValue input) { + return new CustomType(input.s().substring("custom:".length())); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(CustomType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class CustomStringConverter implements AttributeConverter { + public CustomStringConverter() { + } + + @Override + public AttributeValue transformFrom(String input) { + return AttributeValue.fromS("custom:" + input); + } + + @Override + public String transformTo(AttributeValue input) { + String stored = input.s(); + return stored.startsWith("custom:") ? stored.substring("custom:".length()) : stored; + } + + @Override + public EnhancedType type() { + return EnhancedType.of(String.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class ObjectStringConverter implements AttributeConverter { + public ObjectStringConverter() { + } + + @Override + public AttributeValue transformFrom(Object input) { + return AttributeValue.fromS("custom"); + } + + @Override + public Object transformTo(AttributeValue input) { + return input.s(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(Object.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class UnsupportedStringConverter implements AttributeConverter { + public UnsupportedStringConverter() { + } + + @Override + public AttributeValue transformFrom(UnsupportedType input) { + return AttributeValue.fromS("custom"); + } + + @Override + public UnsupportedType transformTo(AttributeValue input) { + return new UnsupportedType(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(UnsupportedType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class LinkedListConverter implements AttributeConverter> { + private static final EnhancedType> TYPE = new EnhancedType>() { + }; + + public LinkedListConverter() { + } + + @Override + public AttributeValue transformFrom(LinkedList input) { + List values = new ArrayList<>(); + for (String member : input) { + values.add(AttributeValue.fromS(member)); + } + return AttributeValue.fromL(values); + } + + @Override + public LinkedList transformTo(AttributeValue input) { + LinkedList result = new LinkedList<>(); + for (AttributeValue member : input.l()) { + result.add(member.s()); + } + return result; + } + + @Override + public EnhancedType> type() { + return TYPE; + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.L; + } + } + +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/StaticSchemaConverterTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/StaticSchemaConverterTest.java new file mode 100644 index 000000000000..d0a38413fbde --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/StaticSchemaConverterTest.java @@ -0,0 +1,1322 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.DocumentAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.StringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.UuidAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Tests converter selection and item conversion for static table schemas. + *

+ * The tests cover scalar, enumeration, collection, map, and schema backed document attributes. They also verify null + * handling, configured provider precedence, attribute converters, empty provider lists, recursive member lookup, and + * unsupported attribute declarations during schema construction. + */ +public class StaticSchemaConverterTest { + + @Test + @DisplayName("UUID attribute selects UuidAttributeConverter and round-trips the input") + void converterForAttribute_whenUuidValue_selectsUuidConverterAndReadsEqualValue() { + UUID input = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + UuidItem item = new UuidItem(); + item.setId("id-1"); + item.setValue(input); + EnhancedType type = EnhancedType.of(UUID.class); + + StaticTableSchema schema = StaticTableSchema.builder(UuidItem.class) + .newItemSupplier(UuidItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(UuidItem::getId).setter(UuidItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(UuidItem::getValue).setter(UuidItem::setValue)) + .build(); + + Map map = schema.itemToMap(item, true); + UuidItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(UuidAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.getValue()).isEqualTo(input); + } + + @Test + @DisplayName("Map of String to Integer selects MapAttributeConverter and reads a LinkedHashMap") + void converterForAttribute_whenStringIntegerMap_selectsMapConverterAndReadsLinkedHashMap() { + Map input = new LinkedHashMap<>(); + input.put("a", 1); + input.put("b", 2); + MapItem item = new MapItem(); + item.setId("id-1"); + item.setValue(input); + EnhancedType> type = EnhancedType.mapOf(String.class, Integer.class); + + StaticTableSchema schema = StaticTableSchema.builder(MapItem.class) + .newItemSupplier(MapItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(MapItem::getId).setter(MapItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(MapItem::getValue).setter(MapItem::setValue)) + .build(); + + Map map = schema.itemToMap(item, true); + MapItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(MapAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(read.getValue()).isEqualTo(input).isInstanceOf(LinkedHashMap.class); + } + + @Test + @DisplayName("Schema-bearing document attribute selects DocumentAttributeConverter and reconstructs") + void converterForAttribute_whenDocumentType_selectsDocumentConverterAndReconstructs() { + TableSchema documentSchema = documentSchema(); + DocumentType input = new DocumentType(); + input.setName("doc"); + DocumentItem item = new DocumentItem(); + item.setId("id-1"); + item.setValue(input); + EnhancedType type = EnhancedType.documentOf(DocumentType.class, documentSchema); + + StaticTableSchema schema = StaticTableSchema.builder(DocumentItem.class) + .newItemSupplier(DocumentItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(DocumentItem::getId).setter(DocumentItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(DocumentItem::getValue).setter(DocumentItem::setValue)) + .build(); + + Map map = schema.itemToMap(item, true); + DocumentItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(DocumentAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(map.get("value").m()).containsEntry("name", AttributeValue.fromS("doc")); + assertThat(read.getValue().getName()).isEqualTo("doc"); + } + + @Test + @DisplayName("Null string is stored as DynamoDB NULL when ignore-nulls is false") + void itemToMap_whenNullStringIgnoreNullsFalse_containsNulValue() { + StringItem item = new StringItem(); + item.setId("id-1"); + StaticTableSchema schema = stringSchema(); + + Map map = schema.itemToMap(item, false); + + assertThat(map).containsEntry("value", AttributeValue.fromNul(true)); + } + + @Test + @DisplayName("Null string is omitted when ignore-nulls is true") + void itemToMap_whenNullStringIgnoreNullsTrue_omitsValue() { + StringItem item = new StringItem(); + item.setId("id-1"); + StaticTableSchema schema = stringSchema(); + + Map map = schema.itemToMap(item, true); + + assertThat(map).doesNotContainKey("value"); + } + + @Test + @DisplayName("DynamoDB NULL is skipped on read and leaves the setter uncalled") + void mapToItem_whenNulValue_doesNotCallSetterAndLeavesValueNull() { + AtomicInteger setterCalls = new AtomicInteger(); + StaticTableSchema schema = StaticTableSchema.builder(StringItem.class) + .newItemSupplier(StringItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(StringItem::getId).setter(StringItem::setId).tags(primaryPartitionKey())) + .addAttribute(EnhancedType.of(String.class), a -> a.name("value") + .getter(StringItem::getValue) + .setter((item, value) -> { + setterCalls.incrementAndGet(); + item.setValue(value); + })) + .build(); + Map map = new LinkedHashMap<>(); + map.put("id", AttributeValue.fromS("id-1")); + map.put("value", AttributeValue.fromNul(true)); + + StringItem read = schema.mapToItem(map); + + assertThat(read).isNotNull(); + assertThat(setterCalls.get()).isZero(); + assertThat(read.getValue()).isNull(); + } + + @Test + @DisplayName("Unconverted Object attribute fails converter lookup") + void build_whenUnconvertedObject_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(Object.class); + + assertThatThrownBy(() -> StaticTableSchema.builder(ObjectItem.class) + .newItemSupplier(ObjectItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(ObjectItem::getId).setter(ObjectItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(ObjectItem::getValue).setter(ObjectItem::setValue)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Unsupported attribute fails lookup with the enclosing type in the message") + void build_whenUnsupportedType_throwsConverterNotFound() { + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + assertThatThrownBy(() -> StaticTableSchema.builder(UnsupportedItem.class) + .newItemSupplier(UnsupportedItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(UnsupportedItem::getId).setter(UnsupportedItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(UnsupportedItem::getValue).setter(UnsupportedItem::setValue)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Custom provider before default selects CustomTypeConverter") + void build_whenRecordingCustomBeforeDefault_selectsCustomTypeConverter() { + CustomType input = new CustomType("x"); + CustomTypeItem item = new CustomTypeItem(); + item.setValue(input); + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingCustomProvider custom = new RecordingCustomProvider(); + + StaticTableSchema schema = StaticTableSchema.builder(CustomTypeItem.class) + .newItemSupplier(CustomTypeItem::new) + .attributeConverterProviders(custom, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(CustomTypeItem::getValue).setter(CustomTypeItem::setValue).tags(primaryPartitionKey())) + .build(); + + Map map = schema.itemToMap(item, true); + CustomTypeItem read = schema.mapToItem(map); + + assertThat(custom.requestedTypes()).hasSize(2); + assertThat(schema.converterForAttribute("value")).isInstanceOf(CustomTypeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.getValue()).isEqualTo(input); + } + + @Test + @DisplayName("Null-returning provider falls back to the default string converter") + void build_whenReturningNullBeforeDefault_selectsStringAttributeConverter() { + EnhancedType type = EnhancedType.of(String.class); + ReturningNullProvider custom = new ReturningNullProvider(); + + StaticTableSchema schema = StaticTableSchema.builder(StringItem.class) + .newItemSupplier(StringItem::new) + .attributeConverterProviders(custom, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(StringItem::getValue).setter(StringItem::setValue).tags(primaryPartitionKey())) + .build(); + + assertThat(custom.requestedTypes()).hasSize(1); + assertThat(schema.converterForAttribute("value")).isInstanceOf(StringAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + } + + @Test + @DisplayName("Default provider before custom blocks later custom fallback") + void build_whenDefaultBeforeRecordingCustom_throwsAndDoesNotInvokeCustom() { + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingCustomProvider custom = new RecordingCustomProvider(); + + assertThatThrownBy(() -> StaticTableSchema.builder(CustomTypeItem.class) + .newItemSupplier(CustomTypeItem::new) + .attributeConverterProviders(DefaultAttributeConverterProvider.create(), custom) + .addAttribute(type, a -> a.name("value") + .getter(CustomTypeItem::getValue).setter(CustomTypeItem::setValue).tags(primaryPartitionKey())) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(custom.requestedTypes()).isEmpty(); + } + + @Test + @DisplayName("Throwing provider exception is propagated without consulting the default") + void build_whenThrowingProviderBeforeDefault_propagatesProviderFailure() { + EnhancedType type = EnhancedType.of(CustomType.class); + RecordingDefaultProvider defaultProvider = new RecordingDefaultProvider(); + + assertThatThrownBy(() -> StaticTableSchema.builder(CustomTypeItem.class) + .newItemSupplier(CustomTypeItem::new) + .attributeConverterProviders(new ThrowingProvider(), defaultProvider) + .addAttribute(type, a -> a.name("value") + .getter(CustomTypeItem::getValue).setter(CustomTypeItem::setValue).tags(primaryPartitionKey())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Attribute converter provider failed while looking up " + type); + assertThat(defaultProvider.requestedTypes()).isEmpty(); + } + + @Test + @DisplayName("Static mutable ObjectProvider before default selects ObjectStringConverter") + void build_whenObjectProviderBeforeDefault_selectsObjectStringConverter() { + ObjectItem item = new ObjectItem(); + item.setValue(new Object()); + EnhancedType type = EnhancedType.of(Object.class); + ObjectProvider provider = new ObjectProvider(); + + StaticTableSchema schema = StaticTableSchema.builder(ObjectItem.class) + .newItemSupplier(ObjectItem::new) + .attributeConverterProviders(provider, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(ObjectItem::getValue).setter(ObjectItem::setValue).tags(primaryPartitionKey())) + .build(); + + Map map = schema.itemToMap(item, true); + ObjectItem read = schema.mapToItem(map); + + int objectRequestCount = 0; + for (EnhancedType requestedType : provider.requestedTypes()) { + if (EnhancedType.of(Object.class).equals(requestedType)) { + objectRequestCount++; + } + } + assertThat(objectRequestCount).isEqualTo(2); + assertThat(schema.converterForAttribute("value")).isInstanceOf(ObjectStringConverter.class); + assertThat(read.getValue()).isEqualTo("custom"); + } + + @Test + @DisplayName("Custom provider intercepts a captured HashMap token before the default") + void build_whenHashMapProviderBeforeDefault_selectsHashMapConverter() { + HashMap input = new HashMap<>(); + input.put("a", 1); + HashMapItem item = new HashMapItem(); + item.setId("id-1"); + item.setValue(input); + EnhancedType> type = new EnhancedType>() { + }; + ExactTypeProvider custom = new ExactTypeProvider(type, new HashMapConverter()); + AtomicReference assigned = new AtomicReference<>(); + + StaticTableSchema schema = StaticTableSchema.builder(HashMapItem.class) + .newItemSupplier(HashMapItem::new) + .attributeConverterProviders(custom, DefaultAttributeConverterProvider.create()) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(HashMapItem::getId).setter(HashMapItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(HashMapItem::getValue) + .setter((model, value) -> { + assigned.set(value); + model.setValue(value); + })) + .build(); + + Map map = schema.itemToMap(item, true); + schema.mapToItem(map); + + assertThat(custom.requestCountFor(type)).isEqualTo(2); + assertThat(schema.converterForAttribute("value")).isInstanceOf(HashMapConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.M); + assertThat(assigned.get()).isInstanceOf(HashMap.class); + } + + @Test + @DisplayName("Attribute-level converter intercepts a schema-bearing Object token") + void build_whenDocumentObjectWithAttributeConverter_skipsProvider() { + ObjectItem item = new ObjectItem(); + item.setId("id-1"); + item.setValue(new Object()); + TableSchema objectSchema = + StaticTableSchema.builder(Object.class).newItemSupplier(Object::new).build(); + EnhancedType type = EnhancedType.documentOf(Object.class, objectSchema); + ReturningNullProvider provider = new ReturningNullProvider(); + AtomicReference assigned = new AtomicReference<>(); + + StaticTableSchema schema = StaticTableSchema.builder(ObjectItem.class) + .newItemSupplier(ObjectItem::new) + .attributeConverterProviders(provider, DefaultAttributeConverterProvider.create()) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(ObjectItem::getId).setter(ObjectItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(ObjectItem::getValue) + .setter((model, value) -> { + assigned.set(value); + model.setValue(value); + }) + .attributeConverter(new ObjectStringConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + schema.mapToItem(map); + + assertThat(provider.requestedTypes()).doesNotContain(type); + assertThat(schema.converterForAttribute("value")).isInstanceOf(ObjectStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(assigned.get()).isEqualTo("custom"); + } + + @Test + @DisplayName("Attribute-level ObjectStringConverter intercepts Object without consulting the provider") + void converterForAttribute_whenObjectStringConverter_skipsProviderAndReadsConverterValue() { + ObjectItem item = new ObjectItem(); + item.setValue(new Object()); + EnhancedType type = EnhancedType.of(Object.class); + ObjectProvider provider = new ObjectProvider(); + + StaticTableSchema schema = StaticTableSchema.builder(ObjectItem.class) + .newItemSupplier(ObjectItem::new) + .attributeConverterProviders(provider, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(ObjectItem::getValue).setter(ObjectItem::setValue).tags(primaryPartitionKey()) + .attributeConverter(new ObjectStringConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + ObjectItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(ObjectStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(provider.requestedTypes()).doesNotContain(type); + assertThat(read.getValue()).isEqualTo("custom"); + } + + @Test + @DisplayName("Attribute-level UnsupportedStringConverter intercepts an unsupported class") + void converterForAttribute_whenUnsupportedStringConverter_readsEqualValue() { + UnsupportedType input = new UnsupportedType(); + UnsupportedItem item = new UnsupportedItem(); + item.setValue(input); + EnhancedType type = EnhancedType.of(UnsupportedType.class); + + StaticTableSchema schema = StaticTableSchema.builder(UnsupportedItem.class) + .newItemSupplier(UnsupportedItem::new) + .addAttribute(type, a -> a.name("value") + .getter(UnsupportedItem::getValue).setter(UnsupportedItem::setValue) + .tags(primaryPartitionKey()) + .attributeConverter(new UnsupportedStringConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + UnsupportedItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(UnsupportedStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(read.getValue()).isEqualTo(input); + } + + @Test + @DisplayName("Attribute-level HashMapConverter intercepts a captured HashMap type") + void converterForAttribute_whenHashMapConverter_setterReceivesHashMap() { + HashMap input = new HashMap<>(); + input.put("a", 1); + HashMapItem item = new HashMapItem(); + item.setId("id-1"); + item.setValue(input); + EnhancedType> type = new EnhancedType>() { + }; + AtomicReference assigned = new AtomicReference<>(); + + StaticTableSchema schema = StaticTableSchema.builder(HashMapItem.class) + .newItemSupplier(HashMapItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(HashMapItem::getId).setter(HashMapItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(HashMapItem::getValue) + .setter((model, value) -> { + assigned.set(value); + model.setValue(value); + }) + .attributeConverter(new HashMapConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(HashMapConverter.class); + assertThat(schema.converterForAttribute("value").type()).isEqualTo(type); + assertThat(assigned.get()).isInstanceOf(HashMap.class); + } + + @Test + @DisplayName("Empty providers fail without an attribute converter") + void build_whenEmptyProvidersWithoutAttributeConverter_throwsNullPointerException() { + EnhancedType type = EnhancedType.of(String.class); + + assertThatThrownBy(() -> StaticTableSchema.builder(StringItem.class) + .newItemSupplier(StringItem::new) + .attributeConverterProviders(Collections.emptyList()) + .addAttribute(type, a -> a.name("value") + .getter(StringItem::getValue).setter(StringItem::setValue).tags(primaryPartitionKey())) + .build()) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + } + + @Test + @DisplayName("Attribute-level CustomStringConverter survives empty providers") + void itemToMap_whenEmptyProvidersWithCustomStringConverter_writesCustomText() { + StringItem item = new StringItem(); + item.setValue("text"); + EnhancedType type = EnhancedType.of(String.class); + + StaticTableSchema schema = StaticTableSchema.builder(StringItem.class) + .newItemSupplier(StringItem::new) + .attributeConverterProviders(Collections.emptyList()) + .addAttribute(type, a -> a.name("value") + .getter(StringItem::getValue).setter(StringItem::setValue).tags(primaryPartitionKey()) + .attributeConverter(new CustomStringConverter())) + .build(); + + Map map = schema.itemToMap(item, true); + StringItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(CustomStringConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("custom:text"); + assertThat(read.getValue()).isEqualTo("text"); + } + + @Test + @DisplayName("Unconverted captured HashMap fails lookup before assignment") + void build_whenUnconvertedHashMap_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> StaticTableSchema.builder(HashMapItem.class) + .newItemSupplier(HashMapItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(HashMapItem::getId).setter(HashMapItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(HashMapItem::getValue).setter(HashMapItem::setValue)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Custom member converter is not consulted for an enclosing map token") + void build_whenMapOfUnsupportedWithMemberProvider_throwsConverterNotFoundForMapType() { + EnhancedType> type = + EnhancedType.mapOf(String.class, UnsupportedType.class); + UnsupportedMemberProvider custom = new UnsupportedMemberProvider(); + + assertThatThrownBy(() -> StaticTableSchema.builder(UnsupportedMapItem.class) + .newItemSupplier(UnsupportedMapItem::new) + .attributeConverterProviders(custom, DefaultAttributeConverterProvider.create()) + .addAttribute(type, a -> a.name("value") + .getter(UnsupportedMapItem::getValue).setter(UnsupportedMapItem::setValue)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + assertThat(custom.requestedTypes()).containsExactly(type); + } + + @Test + @DisplayName("Collection of String selects SetAttributeConverter and reads a LinkedHashSet") + void converterForAttribute_whenStringCollection_selectsSetConverterAndReadsLinkedHashSet() { + Collection input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + CollectionItem item = new CollectionItem(); + item.setId("id-1"); + item.setValue(input); + EnhancedType> type = EnhancedType.collectionOf(String.class); + + StaticTableSchema schema = StaticTableSchema.builder(CollectionItem.class) + .newItemSupplier(CollectionItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(CollectionItem::getId).setter(CollectionItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(CollectionItem::getValue).setter(CollectionItem::setValue)) + .build(); + + Map map = schema.itemToMap(item, true); + CollectionItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(SetAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.getValue()).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("List of String selects ListAttributeConverter and reads an ArrayList") + void converterForAttribute_whenStringList_selectsListConverterAndReadsArrayList() { + List input = new ArrayList<>(); + input.add("a"); + input.add("b"); + input.add("a"); + ListItem item = new ListItem(); + item.setId("id-1"); + item.setValue(input); + EnhancedType> type = EnhancedType.listOf(String.class); + + StaticTableSchema schema = StaticTableSchema.builder(ListItem.class) + .newItemSupplier(ListItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(ListItem::getId).setter(ListItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(ListItem::getValue).setter(ListItem::setValue)) + .build(); + + Map map = schema.itemToMap(item, true); + ListItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(ListAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.L); + assertThat(read.getValue()).isInstanceOf(ArrayList.class) + .containsExactly("a", "b", "a"); + } + + @Test + @DisplayName("Set of String selects SetAttributeConverter and reads a LinkedHashSet") + void converterForAttribute_whenStringSet_selectsSetConverterAndReadsLinkedHashSet() { + Set input = new LinkedHashSet<>(); + input.add("a"); + input.add("b"); + SetItem item = new SetItem(); + item.setId("id-1"); + item.setValue(input); + EnhancedType> type = EnhancedType.setOf(String.class); + + StaticTableSchema schema = StaticTableSchema.builder(SetItem.class) + .newItemSupplier(SetItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(SetItem::getId).setter(SetItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(SetItem::getValue).setter(SetItem::setValue)) + .build(); + + Map map = schema.itemToMap(item, true); + SetItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(SetAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.SS); + assertThat(read.getValue()).isInstanceOf(LinkedHashSet.class) + .containsExactly("a", "b"); + } + + @Test + @DisplayName("Enum attribute selects EnumAttributeConverter and round-trips") + void converterForAttribute_whenEnumValue_selectsEnumConverterAndReadsEqualValue() { + EnumItem item = new EnumItem(); + item.setId("id-1"); + item.setValue(TestEnum.OPEN); + EnhancedType type = EnhancedType.of(TestEnum.class); + + StaticTableSchema schema = StaticTableSchema.builder(EnumItem.class) + .newItemSupplier(EnumItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(EnumItem::getId).setter(EnumItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(EnumItem::getValue).setter(EnumItem::setValue)) + .build(); + + Map map = schema.itemToMap(item, true); + EnumItem read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("value")).isInstanceOf(EnumAttributeConverter.class); + assertThat(schema.converterForAttribute("value").attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(map.get("value").s()).isEqualTo("OPEN"); + assertThat(read.getValue()).isEqualTo(TestEnum.OPEN); + } + + @Test + @DisplayName("Unconverted captured HashSet fails lookup before assignment") + void build_whenUnconvertedHashSet_throwsConverterNotFound() { + EnhancedType> type = new EnhancedType>() { + }; + + assertThatThrownBy(() -> StaticTableSchema.builder(HashSetItem.class) + .newItemSupplier(HashSetItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(HashSetItem::getId).setter(HashSetItem::setId).tags(primaryPartitionKey())) + .addAttribute(type, a -> a.name("value") + .getter(HashSetItem::getValue).setter(HashSetItem::setValue)) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + private static TableSchema documentSchema() { + return StaticTableSchema.builder(DocumentType.class) + .newItemSupplier(DocumentType::new) + .addAttribute(String.class, a -> a.name("name") + .getter(DocumentType::getName) + .setter(DocumentType::setName)) + .build(); + } + + private static StaticTableSchema stringSchema() { + return StaticTableSchema.builder(StringItem.class) + .newItemSupplier(StringItem::new) + .addAttribute(EnhancedType.of(String.class), a -> a.name("id") + .getter(StringItem::getId).setter(StringItem::setId).tags(primaryPartitionKey())) + .addAttribute(EnhancedType.of(String.class), a -> a.name("value") + .getter(StringItem::getValue).setter(StringItem::setValue)) + .build(); + } + + static final class UuidItem { + private String id; + private UUID value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public UUID getValue() { + return value; + } + + public void setValue(UUID value) { + this.value = value; + } + } + + static final class MapItem { + private String id; + private Map value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getValue() { + return value; + } + + public void setValue(Map value) { + this.value = value; + } + } + + static final class DocumentItem { + private String id; + private DocumentType value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public DocumentType getValue() { + return value; + } + + public void setValue(DocumentType value) { + this.value = value; + } + } + + static final class StringItem { + private String id; + private String value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + static final class ObjectItem { + private String id; + private Object value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } + } + + static final class UnsupportedItem { + private String id; + private UnsupportedType value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public UnsupportedType getValue() { + return value; + } + + public void setValue(UnsupportedType value) { + this.value = value; + } + } + + static final class CustomTypeItem { + private CustomType value; + + public CustomType getValue() { + return value; + } + + public void setValue(CustomType value) { + this.value = value; + } + } + + static final class HashMapItem { + private String id; + private HashMap value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public HashMap getValue() { + return value; + } + + public void setValue(HashMap value) { + this.value = value; + } + } + + static final class UnsupportedMapItem { + private String id; + private Map value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getValue() { + return value; + } + + public void setValue(Map value) { + this.value = value; + } + } + + static final class CollectionItem { + private String id; + private Collection value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Collection getValue() { + return value; + } + + public void setValue(Collection value) { + this.value = value; + } + } + + static final class ListItem { + private String id; + private List value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public List getValue() { + return value; + } + + public void setValue(List value) { + this.value = value; + } + } + + static final class SetItem { + private String id; + private Set value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Set getValue() { + return value; + } + + public void setValue(Set value) { + this.value = value; + } + } + + static final class EnumItem { + private String id; + private TestEnum value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public TestEnum getValue() { + return value; + } + + public void setValue(TestEnum value) { + this.value = value; + } + } + + static final class HashSetItem { + private String id; + private HashSet value; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public HashSet getValue() { + return value; + } + + public void setValue(HashSet value) { + this.value = value; + } + } + + enum TestEnum { + OPEN, + CLOSED + } + + static final class CustomType { + private final String value; + + CustomType(String value) { + this.value = value; + } + + String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CustomType)) { + return false; + } + CustomType that = (CustomType) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } + + static final class UnsupportedType { + @Override + public boolean equals(Object o) { + return o instanceof UnsupportedType; + } + + @Override + public int hashCode() { + return 1; + } + } + + static final class DocumentType { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + public static final class RecordingCustomProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public RecordingCustomProvider() { + CURRENT.set(this); + } + + public static RecordingCustomProvider current() { + return CURRENT.get(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(CustomType.class).equals(enhancedType)) { + return (AttributeConverter) new CustomTypeConverter(); + } + return null; + } + } + + public static final class ReturningNullProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public ReturningNullProvider() { + CURRENT.set(this); + } + + public static ReturningNullProvider current() { + return CURRENT.get(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return null; + } + } + + public static final class ThrowingProvider implements AttributeConverterProvider { + public ThrowingProvider() { + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + throw new IllegalArgumentException("Attribute converter provider failed while looking up " + enhancedType); + } + } + + public static final class ObjectProvider implements AttributeConverterProvider { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final List> requestedTypes = new ArrayList<>(); + + public ObjectProvider() { + CURRENT.set(this); + } + + public static ObjectProvider current() { + return CURRENT.get(); + } + + public List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(Object.class).equals(enhancedType)) { + return (AttributeConverter) new ObjectStringConverter(); + } + return null; + } + } + + static final class ExactTypeProvider implements AttributeConverterProvider { + private final EnhancedType target; + private final AttributeConverter converter; + private final List> requestedTypes = new ArrayList<>(); + + ExactTypeProvider(EnhancedType target, AttributeConverter converter) { + this.target = target; + this.converter = converter; + } + + int requestCountFor(EnhancedType type) { + int count = 0; + for (EnhancedType requestedType : requestedTypes) { + if (type.equals(requestedType)) { + count++; + } + } + return count; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (target.equals(enhancedType)) { + return (AttributeConverter) converter; + } + return null; + } + } + + static final class RecordingDefaultProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + private final AttributeConverterProvider delegate = DefaultAttributeConverterProvider.create(); + + List> requestedTypes() { + return requestedTypes; + } + + @Override + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + return delegate.converterFor(enhancedType); + } + } + + static final class UnsupportedMemberProvider implements AttributeConverterProvider { + private final List> requestedTypes = new ArrayList<>(); + + List> requestedTypes() { + return requestedTypes; + } + + @Override + @SuppressWarnings("unchecked") + public AttributeConverter converterFor(EnhancedType enhancedType) { + requestedTypes.add(enhancedType); + if (EnhancedType.of(UnsupportedType.class).equals(enhancedType)) { + return (AttributeConverter) new UnsupportedStringConverter(); + } + return null; + } + } + + public static final class CustomTypeConverter implements AttributeConverter { + + @Override + public AttributeValue transformFrom(CustomType input) { + return AttributeValue.fromS("custom:" + input.value()); + } + + @Override + public CustomType transformTo(AttributeValue input) { + return new CustomType(input.s().substring("custom:".length())); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(CustomType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class CustomStringConverter implements AttributeConverter { + + @Override + public AttributeValue transformFrom(String input) { + return AttributeValue.fromS("custom:" + input); + } + + @Override + public String transformTo(AttributeValue input) { + String stored = input.s(); + return stored.startsWith("custom:") ? stored.substring("custom:".length()) : stored; + } + + @Override + public EnhancedType type() { + return EnhancedType.of(String.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class ObjectStringConverter implements AttributeConverter { + public ObjectStringConverter() { + } + + @Override + public AttributeValue transformFrom(Object input) { + return AttributeValue.fromS("custom"); + } + + @Override + public Object transformTo(AttributeValue input) { + return input.s(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(Object.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class UnsupportedStringConverter implements AttributeConverter { + + @Override + public AttributeValue transformFrom(UnsupportedType input) { + return AttributeValue.fromS("custom"); + } + + @Override + public UnsupportedType transformTo(AttributeValue input) { + return new UnsupportedType(); + } + + @Override + public EnhancedType type() { + return EnhancedType.of(UnsupportedType.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.S; + } + } + + public static final class HashMapConverter implements AttributeConverter> { + private static final EnhancedType> TYPE = + new EnhancedType>() { + }; + + public HashMapConverter() { + } + + @Override + public AttributeValue transformFrom(HashMap input) { + Map values = new LinkedHashMap<>(); + for (Map.Entry entry : input.entrySet()) { + values.put(entry.getKey(), AttributeValue.fromN(Integer.toString(entry.getValue()))); + } + return AttributeValue.fromM(values); + } + + @Override + public HashMap transformTo(AttributeValue input) { + HashMap result = new HashMap<>(); + for (Map.Entry entry : input.m().entrySet()) { + result.put(entry.getKey(), Integer.valueOf(entry.getValue().n())); + } + return result; + } + + @Override + public EnhancedType> type() { + return TYPE; + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.M; + } + } + +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderOperationsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderOperationsTest.java new file mode 100644 index 000000000000..b6d035623964 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderOperationsTest.java @@ -0,0 +1,646 @@ +/* + * 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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.LocalDynamoDbAsyncTestBase.drainPublisher; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.ITEM_ID; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.assertReconstructedCollections; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.assertWrittenItem; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.completeItem; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.completeItemWithNullableNull; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.countersAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.eventsAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.eventsWithNullElementAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.labelsAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.oneCounter; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.oneLabel; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedConverterRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.typedCollectionsDocumentWithNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +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.functionaltests.models.DefaultAttributeConverterProviderTestModels.ConverterRecord; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; +import software.amazon.awssdk.enhanced.dynamodb.model.WriteBatch; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; + +/** + * Tests collection conversion during DynamoDB Enhanced Client table operations. + *

+ * The tests exercise scans, queries, individual writes and reads, batch operations, and transactions with synchronous + * and asynchronous clients. They verify stored collection forms, reconstructed collection order, explicit and omitted + * null values, and conversion failures before or during an operation. + */ +public class DefaultAttributeConverterProviderOperationsTest extends LocalDynamoDbTestBase { + + @BeforeAll + public static void startLocalDynamoDb() { + localDynamoDb().start(); + } + + @AfterAll + public static void stopLocalDynamoDbForJunit5() { + localDynamoDb().stop(); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Scan returns stored collection attributes in insertion order") + public void scan_whenTableContainsCollectionItem_reconstructsLinkedCollections(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putItem(populatedConverterRecord()); + + List items = operations.scanItems(); + + assertThat(items).hasSize(1); + assertReconstructedCollections(items.get(0)); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Query returns stored collection attributes in insertion order") + public void query_whenTableContainsCollectionItem_reconstructsLinkedCollections(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putItem(populatedConverterRecord()); + + List items = operations.queryByPartition(ITEM_ID); + + assertThat(items).hasSize(1); + assertReconstructedCollections(items.get(0)); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Update persists collection attributes and returns them reconstructed") + public void updateItem_whenRecordHasCollections_writesAndReconstructsCollections(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + ConverterRecord result = operations.updateItem(populatedConverterRecord()); + + assertWrittenItem(operations.storedItem()); + assertReconstructedCollections(result); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Put persists the key and collection attribute forms") + public void putItem_whenRecordHasCollections_writesKeyAndCollectionAttributeForms(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putItem(populatedConverterRecord()); + + assertWrittenItem(operations.storedItem()); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Get returns stored collection attributes in insertion order") + public void putItemThenGetItem_whenTableContainsCompleteItem_reconstructsLinkedCollections( + DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putItem(populatedConverterRecord()); + + ConverterRecord result = operations.getItem(itemKey()); + + assertReconstructedCollections(result); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Delete returns the previous collection attributes and removes the item") + public void deleteItem_whenTableContainsCompleteItem_returnsOldCollections(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putItem(populatedConverterRecord()); + + ConverterRecord result = operations.deleteItem(itemKey()); + + assertReconstructedCollections(result); + assertThat(operations.getItem(itemKey())).isNull(); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Batch get returns stored collection attributes in insertion order") + public void batchGetItem_whenTableContainsCompleteItem_reconstructsLinkedCollections(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putItem(populatedConverterRecord()); + + List items = operations.batchGetItem(ITEM_ID); + + assertThat(items).hasSize(1); + assertReconstructedCollections(items.get(0)); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Batch write persists the key and collection attribute forms") + public void batchWriteItem_whenPutContainsCollections_writesKeyAndCollectionAttributeForms( + DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.batchWriteItem(populatedConverterRecord()); + + assertWrittenItem(operations.storedItem()); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Transact get returns stored collection attributes in insertion order") + public void transactGetItems_whenTableContainsCompleteItem_reconstructsLinkedCollections( + DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putItem(populatedConverterRecord()); + + List items = operations.transactGetItems(ITEM_ID); + + assertThat(items).hasSize(1); + assertReconstructedCollections(items.get(0)); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Transact write persists the key and collection attribute forms") + public void transactWriteItems_whenPutContainsCollections_writesKeyAndCollectionAttributeForms( + DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.transactWriteItems(populatedConverterRecord()); + + assertWrittenItem(operations.storedItem()); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Get fails when a stored list attribute is a map") + public void getItem_whenStoredEventsAreAMap_throwsIllegalStateException(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + Map item = new LinkedHashMap<>(completeItem()); + item.put("events", AttributeValue.fromM(Collections.emptyMap())); + operations.putRawItem(item); + + assertThatThrownBy(() -> operations.getItem(itemKey())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute." + + "ListAttributeConverter cannot convert an attribute of type M into the requested type " + + "interface java.util.List"); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Put omits a null map attribute and still writes the set and list") + public void putItem_whenCountersAreNull_omitsCountersAndWritesSetAndList(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + ConverterRecord record = populatedConverterRecord(); + record.setCounters(null); + + operations.putItem(record); + + Map item = operations.storedItem(); + assertThat(item).doesNotContainKey("counters"); + assertThat(item.get("labels")).isEqualTo(labelsAttribute()); + assertThat(item.get("events")).isEqualTo(eventsAttribute()); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Put stores a null list member and get returns that null") + public void putItemThenGetItem_whenListContainsNullElement_writesNullMemberAndReadsArrayListWithNull( + DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + ConverterRecord record = populatedConverterRecord(); + ArrayList events = new ArrayList<>(); + events.add("a"); + events.add(null); + events.add("b"); + record.setEvents(events); + + operations.putItem(record); + + assertThat(operations.storedItem().get("events")).isEqualTo(eventsWithNullElementAttribute()); + + ConverterRecord result = operations.getItem(itemKey()); + + assertThat(result.getEvents()).isInstanceOf(ArrayList.class).containsExactly("a", null, "b"); + assertThat(result.getCounters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(result.getLabels()).isEqualTo(oneLabel()); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Put fails before the request when a map value is null") + public void putItem_whenMapValueIsNull_throwsNullPointerExceptionBeforeRequest(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + ConverterRecord record = populatedConverterRecord(); + LinkedHashMap counters = new LinkedHashMap<>(); + counters.put("missing", null); + record.setCounters(counters); + + assertThatThrownBy(() -> operations.putItem(record)) + .isInstanceOf(NullPointerException.class) + .satisfies(ex -> assertThat(ex.getMessage() == null || ex.getMessage().contains("null")).isTrue()); + assertThat(operations.getItem(itemKey())).isNull(); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Put of a document stores explicit null together with typed collections") + public void putItem_whenDocumentHasPutNull_writesNullAndTypedCollections(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putDocument(typedCollectionsDocumentWithNull()); + + Map item = operations.storedItem(); + assertThat(item.get("nullable")).isEqualTo(AttributeValue.fromNul(true)); + assertThat(item.get("counters")).isEqualTo(countersAttribute()); + assertThat(item.get("labels")).isEqualTo(labelsAttribute()); + assertThat(item.get("events")).isEqualTo(eventsAttribute()); + } finally { + operations.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Get leaves a null property unset and reconstructs the collections") + public void getItem_whenNullableIsDynamoDbNull_skipsSetterAndReconstructsCollections(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = openTable(clientType); + try { + operations.putRawItem(completeItemWithNullableNull()); + + ConverterRecord result = operations.getItem(itemKey()); + + assertThat(result.getNullable()).isNull(); + assertReconstructedCollections(result); + } finally { + operations.deleteTable(); + } + } + + private ConverterRecordOperations openTable(DynamoDbEnhancedClientType clientType) { + ConverterRecordOperations operations = clientType == DynamoDbEnhancedClientType.SYNC + ? new SyncOperations() + : new AsyncOperations(); + operations.createTable(); + return operations; + } + + private static Key itemKey() { + return Key.builder().partitionValue(ITEM_ID).build(); + } + + private static T joinFuture(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException completionException) { + Throwable cause = completionException.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw completionException; + } + } + + private interface ConverterRecordOperations { + void createTable(); + + void deleteTable(); + + void putItem(ConverterRecord record); + + ConverterRecord getItem(Key key); + + ConverterRecord updateItem(ConverterRecord record); + + ConverterRecord deleteItem(Key key); + + List scanItems(); + + List queryByPartition(String partitionValue); + + List batchGetItem(String partitionValue); + + void batchWriteItem(ConverterRecord record); + + List transactGetItems(String partitionValue); + + void transactWriteItems(ConverterRecord record); + + void putDocument(EnhancedDocument document); + + void putRawItem(Map item); + + Map storedItem(); + } + + private final class SyncOperations implements ConverterRecordOperations { + private final DynamoDbClient dynamoDbClient = localDynamoDb().createClient(); + private final DynamoDbEnhancedClient enhancedClient = + DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build(); + private final String tableName = getConcreteTableName("table-name"); + private final DynamoDbTable table = + enhancedClient.table(tableName, TableSchema.fromBean(ConverterRecord.class)); + private final DynamoDbTable documentTable = + enhancedClient.table(tableName, + TableSchema.documentSchemaBuilder() + .addIndexPartitionKey(TableMetadata.primaryIndexName(), "id", + AttributeValueType.S) + .build()); + + @Override + public void createTable() { + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @Override + public void deleteTable() { + dynamoDbClient.deleteTable(DeleteTableRequest.builder().tableName(tableName).build()); + } + + @Override + public void putItem(ConverterRecord record) { + table.putItem(record); + } + + @Override + public ConverterRecord getItem(Key key) { + return table.getItem(key); + } + + @Override + public ConverterRecord updateItem(ConverterRecord record) { + return table.updateItem(record); + } + + @Override + public ConverterRecord deleteItem(Key key) { + return table.deleteItem(key); + } + + @Override + public List scanItems() { + List items = new ArrayList<>(); + for (ConverterRecord item : table.scan().items()) { + items.add(item); + } + return items; + } + + @Override + public List queryByPartition(String partitionValue) { + List items = new ArrayList<>(); + for (ConverterRecord item : table.query(QueryConditional.keyEqualTo(k -> k.partitionValue(partitionValue))) + .items()) { + items.add(item); + } + return items; + } + + @Override + public List batchGetItem(String partitionValue) { + List items = new ArrayList<>(); + for (ConverterRecord item : enhancedClient.batchGetItem(r -> r.readBatches( + ReadBatch.builder(ConverterRecord.class) + .mappedTableResource(table) + .addGetItem(i -> i.key(k -> k.partitionValue(partitionValue))) + .build())).resultsForTable(table)) { + items.add(item); + } + return items; + } + + @Override + public void batchWriteItem(ConverterRecord record) { + enhancedClient.batchWriteItem(r -> r.writeBatches( + WriteBatch.builder(ConverterRecord.class) + .mappedTableResource(table) + .addPutItem(record) + .build())); + } + + @Override + public List transactGetItems(String partitionValue) { + return enhancedClient.transactGetItems(r -> r.addGetItem(table, Key.builder().partitionValue(partitionValue).build())).stream().map(document -> document.getItem(table)).collect(Collectors.toList()); + } + + @Override + public void transactWriteItems(ConverterRecord record) { + enhancedClient.transactWriteItems(r -> r.addPutItem(table, record)); + } + + @Override + public void putDocument(EnhancedDocument document) { + documentTable.putItem(document); + } + + @Override + public void putRawItem(Map item) { + dynamoDbClient.putItem(r -> r.tableName(tableName).item(item)); + } + + @Override + public Map storedItem() { + return dynamoDbClient.getItem(r -> r.tableName(tableName) + .key(Collections.singletonMap("id", AttributeValue.fromS(ITEM_ID))) + .consistentRead(true)) + .item(); + } + } + + private final class AsyncOperations implements ConverterRecordOperations { + private final DynamoDbAsyncClient dynamoDbAsyncClient = localDynamoDb().createAsyncClient(); + private final DynamoDbEnhancedAsyncClient enhancedClient = + DynamoDbEnhancedAsyncClient.builder().dynamoDbClient(dynamoDbAsyncClient).build(); + private final String tableName = getConcreteTableName("table-name"); + private final DynamoDbAsyncTable table = + enhancedClient.table(tableName, TableSchema.fromBean(ConverterRecord.class)); + private final DynamoDbAsyncTable documentTable = + enhancedClient.table(tableName, + TableSchema.documentSchemaBuilder() + .addIndexPartitionKey(TableMetadata.primaryIndexName(), "id", + AttributeValueType.S) + .build()); + + @Override + public void createTable() { + joinFuture(table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()))); + } + + @Override + public void deleteTable() { + joinFuture(dynamoDbAsyncClient.deleteTable(DeleteTableRequest.builder().tableName(tableName).build())); + } + + @Override + public void putItem(ConverterRecord record) { + joinFuture(table.putItem(record)); + } + + @Override + public ConverterRecord getItem(Key key) { + return joinFuture(table.getItem(key)); + } + + @Override + public ConverterRecord updateItem(ConverterRecord record) { + return joinFuture(table.updateItem(record)); + } + + @Override + public ConverterRecord deleteItem(Key key) { + return joinFuture(table.deleteItem(key)); + } + + @Override + public List scanItems() { + return drainPublisher(table.scan().items(), 1); + } + + @Override + public List queryByPartition(String partitionValue) { + return drainPublisher( + table.query(QueryConditional.keyEqualTo(k -> k.partitionValue(partitionValue))).items(), 1); + } + + @Override + public List batchGetItem(String partitionValue) { + return drainPublisher(enhancedClient.batchGetItem(r -> r.readBatches( + ReadBatch.builder(ConverterRecord.class) + .mappedTableResource(table) + .addGetItem(i -> i.key(k -> k.partitionValue(partitionValue))) + .build())).resultsForTable(table), 1); + } + + @Override + public void batchWriteItem(ConverterRecord record) { + joinFuture(enhancedClient.batchWriteItem(r -> r.writeBatches( + WriteBatch.builder(ConverterRecord.class) + .mappedTableResource(table) + .addPutItem(record) + .build()))); + } + + @Override + public List transactGetItems(String partitionValue) { + return joinFuture(enhancedClient.transactGetItems( + r -> r.addGetItem(table, Key.builder().partitionValue(partitionValue).build()))).stream().map(document -> document.getItem(table)).collect(Collectors.toList()); + } + + @Override + public void transactWriteItems(ConverterRecord record) { + joinFuture(enhancedClient.transactWriteItems(r -> r.addPutItem(table, record))); + } + + @Override + public void putDocument(EnhancedDocument document) { + joinFuture(documentTable.putItem(document)); + } + + @Override + public void putRawItem(Map item) { + joinFuture(dynamoDbAsyncClient.putItem(r -> r.tableName(tableName).item(item))); + } + + @Override + public Map storedItem() { + return dynamoDbAsyncClient.getItem(r -> r.tableName(tableName) + .key(Collections.singletonMap("id", AttributeValue.fromS(ITEM_ID))) + .consistentRead(true)) + .join() + .item(); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderSchemaTypesTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderSchemaTypesTest.java new file mode 100644 index 000000000000..37d7345dddcd --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderSchemaTypesTest.java @@ -0,0 +1,648 @@ +/* + * 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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.assertReconstructedCollections; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.assertWrittenItem; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.DETAILS_NOTE; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.DIRECT_VALUE; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.ITEM_ID; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.allBranchesDocument; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.allBranchesStaticImmutableSchema; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.allBranchesStaticSchema; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.completeItem; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.countersAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.detailsSchema; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.eventsAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.labelsAttribute; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.oneCounter; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.oneEvent; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.oneLabel; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedAllBranchesBean; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedAllBranchesImmutable; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedAllBranchesStaticImmutable; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedAllBranchesStaticRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedConverterImmutable; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedConverterRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedDefaultProviderBean; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedDefaultProviderImmutable; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedStaticImmutableRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.populatedStaticRecord; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.staticImmutableRecordSchema; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.staticRecordSchema; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.typedCollectionsDocument; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; +import software.amazon.awssdk.enhanced.dynamodb.DefaultAttributeConverterProvider; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbAsyncTable; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedAsyncClient; +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.IndexMetadata; +import software.amazon.awssdk.enhanced.dynamodb.Key; +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.functionaltests.models.DefaultAttributeConverterProviderTestModels.AllBranchesBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.AllBranchesDetails; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.AllBranchesImmutable; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithArrayList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithHashMap; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithHashSet; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithObject; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithUnsupported; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.AllBranchesStaticImmutable; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.AllBranchesStaticRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.ConverterImmutable; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.ConverterRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.DefaultProviderBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.DefaultProviderImmutable; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.FlattenedChildBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.OuterFlattenedBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.SingleGsiPartitionBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.SingleGsiSortBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.StaticImmutableRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.StaticRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.TestEnum; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.UnsupportedType; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.ListAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.MapAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.SetAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.mapper.Order; +import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; + +/** + * Schema factories for bean, immutable, static mutable, static immutable, and document types. + *

+ * In-memory factory cases run once. They cover successful conversion, construction failures, omitted + * providers, flatten, and default GSI key order. + * Table put and get cases run for each {@link DynamoDbEnhancedClientType}. + */ +public class DefaultAttributeConverterProviderSchemaTypesTest extends LocalDynamoDbTestBase { + + @BeforeAll + public static void startLocalDynamoDb() { + localDynamoDb().start(); + } + + @AfterAll + public static void stopLocalDynamoDbForJunit5() { + localDynamoDb().stop(); + } + + @BeforeEach + public void clearBeanSchemaCache() throws Exception { + Method clear = BeanTableSchema.class.getDeclaredMethod("clearSchemaCache"); + clear.setAccessible(true); + clear.invoke(null); + } + + @Test + @DisplayName("Bean schema converts supported collections to DynamoDB and back") + public void itemToMapThenMapToItem_whenFromBeanConverterRecordIsPopulated_roundTripsSupportedCollections() { + TableSchema schema = TableSchema.fromBean(ConverterRecord.class); + ConverterRecord item = populatedConverterRecord(); + + Map map = schema.itemToMap(item, true); + ConverterRecord read = schema.mapToItem(map); + + assertWrittenCollections(map); + assertReconstructedCollections(read); + } + + @Test + @DisplayName("Immutable schema converts supported collections to DynamoDB and back") + public void itemToMapThenMapToItem_whenFromImmutableClassIsPopulated_roundTripsSupportedCollections() { + TableSchema schema = TableSchema.fromImmutableClass(ConverterImmutable.class); + ConverterImmutable item = populatedConverterImmutable(); + + Map map = schema.itemToMap(item, true); + ConverterImmutable read = schema.mapToItem(map); + + assertWrittenCollections(map); + assertThat(read.counters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(read.labels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(read.events()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Document schema converts typed collections to DynamoDB and back") + public void itemToMapThenMapToItem_whenDocumentHasTypedCollections_roundTripsSupportedCollections() { + TableSchema schema = TableSchema.documentSchemaBuilder().build(); + EnhancedDocument document = typedCollectionsDocument(); + + Map map = schema.itemToMap(document, false); + EnhancedDocument read = schema.mapToItem(map); + + assertWrittenCollections(map); + assertThat(read.get("counters", EnhancedType.mapOf(String.class, Integer.class))) + .isInstanceOf(LinkedHashMap.class) + .isEqualTo(oneCounter()); + assertThat(read.get("labels", EnhancedType.setOf(String.class))) + .isInstanceOf(LinkedHashSet.class) + .isEqualTo(oneLabel()); + assertThat(read.get("events", EnhancedType.listOf(String.class))) + .isInstanceOf(ArrayList.class) + .isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Static schema converts supported collections to DynamoDB and back") + public void itemToMapThenMapToItem_whenStaticTableSchemaRecordIsPopulated_roundTripsSupportedCollections() { + StaticTableSchema schema = staticRecordSchema(); + StaticRecord item = populatedStaticRecord(); + + Map map = schema.itemToMap(item, true); + StaticRecord read = schema.mapToItem(map); + + assertWrittenCollections(map); + assertThat(read.getCounters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(read.getLabels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(read.getEvents()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Bean schema uses the default provider when converters are omitted") + public void itemToMapThenMapToItem_whenFromBeanConverterProvidersOmitted_roundTripsSupportedCollections() { + DynamoDbBean annotation = DefaultProviderBean.class.getAnnotation(DynamoDbBean.class); + TableSchema schema = TableSchema.fromBean(DefaultProviderBean.class); + DefaultProviderBean item = populatedDefaultProviderBean(); + + Map map = schema.itemToMap(item, true); + DefaultProviderBean read = schema.mapToItem(map); + + assertThat(annotation.converterProviders()).containsExactly(DefaultAttributeConverterProvider.class); + assertWrittenCollections(map); + assertThat(read.getCounters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(read.getLabels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(read.getEvents()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Immutable schema uses the default provider when converters are omitted") + public void itemToMapThenMapToItem_whenFromImmutableConverterProvidersOmitted_roundTripsSupportedCollections() { + DynamoDbImmutable annotation = DefaultProviderImmutable.class.getAnnotation(DynamoDbImmutable.class); + TableSchema schema = + TableSchema.fromImmutableClass(DefaultProviderImmutable.class); + DefaultProviderImmutable item = populatedDefaultProviderImmutable(); + + Map map = schema.itemToMap(item, true); + DefaultProviderImmutable read = schema.mapToItem(map); + + assertThat(annotation.converterProviders()).containsExactly(DefaultAttributeConverterProvider.class); + assertWrittenCollections(map); + assertThat(read.counters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(read.labels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(read.events()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Flattened child collections convert without an explicit flatten bean class") + public void itemToMapThenMapToItem_whenFlattenOmitsBeanClass_roundTripsFlattenedChildCollections() throws Exception { + DynamoDbFlatten flatten = OuterFlattenedBean.class.getMethod("getChild").getAnnotation(DynamoDbFlatten.class); + FlattenedChildBean child = new FlattenedChildBean(); + child.setCounters(oneCounter()); + child.setLabels(oneLabel()); + child.setEvents(oneEvent()); + OuterFlattenedBean item = new OuterFlattenedBean(); + item.setId(ITEM_ID); + item.setChild(child); + TableSchema schema = TableSchema.fromBean(OuterFlattenedBean.class); + + Map map = schema.itemToMap(item, true); + OuterFlattenedBean read = schema.mapToItem(map); + + assertThat(flatten.dynamoDbBeanClass()).isEqualTo(Object.class); + assertWrittenCollections(map); + assertThat(read.getChild().getCounters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(read.getChild().getLabels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(read.getChild().getEvents()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Secondary partition key without order is an unspecified string key") + public void fromBean_whenSecondaryPartitionKeyOmitsOrder_usesUnspecifiedStringKey() throws Exception { + DynamoDbSecondaryPartitionKey annotation = + SingleGsiPartitionBean.class.getMethod("getGsiKey") + .getAnnotation(DynamoDbSecondaryPartitionKey.class); + TableSchema schema = TableSchema.fromBean(SingleGsiPartitionBean.class); + IndexMetadata gsi = index(schema, "gsi"); + + assertThat(annotation.order()).isEqualTo(Order.UNSPECIFIED); + assertThat(gsi.partitionKeys()).hasSize(1); + assertThat(gsi.partitionKeys().get(0).attributeValueType()).isEqualTo(AttributeValueType.S); + assertThat(gsi.partitionKeys().get(0).order()).isEqualTo(Order.UNSPECIFIED); + } + + @Test + @DisplayName("Secondary sort key without order is an unspecified integer key") + public void fromBean_whenSecondarySortKeyOmitsOrder_usesUnspecifiedIntegerKey() throws Exception { + DynamoDbSecondarySortKey annotation = + SingleGsiSortBean.class.getMethod("getGsiSort").getAnnotation(DynamoDbSecondarySortKey.class); + TableSchema schema = TableSchema.fromBean(SingleGsiSortBean.class); + IndexMetadata gsi = index(schema, "gsi"); + + assertThat(annotation.order()).isEqualTo(Order.UNSPECIFIED); + assertThat(gsi.sortKeys()).hasSize(1); + assertThat(gsi.sortKeys().get(0).attributeValueType()).isEqualTo(AttributeValueType.N); + assertThat(gsi.sortKeys().get(0).order()).isEqualTo(Order.UNSPECIFIED); + } + + @Test + @DisplayName("Static schema generates collection converters when providers are omitted") + public void itemToMapThenMapToItem_whenStaticTableSchemaProvidersOmitted_roundTripsGeneratedCollectionConverters() { + StaticTableSchema schema = staticRecordSchema(); + StaticRecord item = populatedStaticRecord(); + + Map map = schema.itemToMap(item, true); + StaticRecord read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("counters")).isInstanceOf(MapAttributeConverter.class); + assertThat(schema.converterForAttribute("labels")).isInstanceOf(SetAttributeConverter.class); + assertThat(schema.converterForAttribute("events")).isInstanceOf(ListAttributeConverter.class); + assertWrittenCollections(map); + assertThat(read.getCounters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(read.getLabels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(read.getEvents()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Static immutable schema generates collection converters when providers are omitted") + public void itemToMapThenMapToItem_whenStaticImmutableProvidersOmitted_roundTripsGeneratedCollectionConverters() { + StaticImmutableTableSchema schema = + staticImmutableRecordSchema(); + StaticImmutableRecord item = populatedStaticImmutableRecord(); + + Map map = schema.itemToMap(item, true); + StaticImmutableRecord read = schema.mapToItem(map); + + assertThat(schema.converterForAttribute("counters")).isInstanceOf(MapAttributeConverter.class); + assertThat(schema.converterForAttribute("labels")).isInstanceOf(SetAttributeConverter.class); + assertThat(schema.converterForAttribute("events")).isInstanceOf(ListAttributeConverter.class); + assertWrittenCollections(map); + assertThat(read.counters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(read.labels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(read.events()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Document schema reads generated collection classes when providers are omitted") + public void documentSchemaBuilder_whenAttributeConverterProvidersOmitted_readsGeneratedCollectionClasses() { + TableSchema schema = TableSchema.documentSchemaBuilder().build(); + Map attributeMap = completeItem(); + + EnhancedDocument read = schema.mapToItem(attributeMap); + + assertThat(read.get("counters", EnhancedType.mapOf(String.class, Integer.class))) + .isInstanceOf(LinkedHashMap.class) + .isEqualTo(oneCounter()); + assertThat(read.get("labels", EnhancedType.setOf(String.class))) + .isInstanceOf(LinkedHashSet.class) + .isEqualTo(oneLabel()); + assertThat(read.get("events", EnhancedType.listOf(String.class))) + .isInstanceOf(ArrayList.class) + .isEqualTo(oneEvent()); + } + + @Test + @DisplayName("Rejects a bean schema when its Object attribute has no converter") + public void fromBean_whenBeanDeclaresObject_throwsConverterNotFound() { + assertThatThrownBy(() -> TableSchema.fromBean(BeanWithObject.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @Test + @DisplayName("Rejects a bean schema that declares an unsupported attribute type") + public void fromBean_whenBeanDeclaresUnsupportedType_throwsIllegalStateException() { + assertThatThrownBy(() -> TableSchema.fromBean(BeanWithUnsupported.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(UnsupportedType.class)); + } + + @Test + @DisplayName("Rejects a bean schema that declares an ArrayList attribute") + public void fromBean_whenBeanDeclaresArrayList_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { }; + assertThatThrownBy(() -> TableSchema.fromBean(BeanWithArrayList.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Rejects a bean schema that declares a HashSet attribute") + public void fromBean_whenBeanDeclaresHashSet_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { }; + assertThatThrownBy(() -> TableSchema.fromBean(BeanWithHashSet.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @Test + @DisplayName("Rejects a bean schema that declares a HashMap attribute") + public void fromBean_whenBeanDeclaresHashMap_throwsIllegalStateException() { + EnhancedType> type = new EnhancedType>() { }; + assertThatThrownBy(() -> TableSchema.fromBean(BeanWithHashMap.class)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Bean table put and get convert every default lookup type") + public void putItemThenGetItem_whenBeanSchemaHasAllLookupTypes_roundTripsConvertedValues( + DynamoDbEnhancedClientType clientType) { + SchemaTableClient client = openTable(clientType); + try { + TableSchema schema = TableSchema.fromBean(AllBranchesBean.class); + + client.putItem(schema, populatedAllBranchesBean()); + AllBranchesBean result = client.getItem(schema, itemKey()); + + assertAllBranchesItem(client.storedItem()); + assertThat(result.getDirect()).isEqualTo(DIRECT_VALUE); + assertThat(result.getStatus()).isEqualTo(TestEnum.OPEN); + assertThat(result.getDetails().getNote()).isEqualTo(DETAILS_NOTE); + assertThat(result.getCounters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(result.getLabels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(result.getEvents()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Immutable table put and get convert every default lookup type") + public void putItemThenGetItem_whenImmutableSchemaHasAllLookupTypes_roundTripsConvertedValues( + DynamoDbEnhancedClientType clientType) { + SchemaTableClient client = openTable(clientType); + try { + TableSchema schema = TableSchema.fromImmutableClass(AllBranchesImmutable.class); + + client.putItem(schema, populatedAllBranchesImmutable()); + AllBranchesImmutable result = client.getItem(schema, itemKey()); + + assertAllBranchesItem(client.storedItem()); + assertAllBranchesImmutable(result); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Static table put and get convert every default lookup type") + public void putItemThenGetItem_whenStaticSchemaHasAllLookupTypes_roundTripsConvertedValues( + DynamoDbEnhancedClientType clientType) { + SchemaTableClient client = openTable(clientType); + try { + TableSchema schema = allBranchesStaticSchema(); + + client.putItem(schema, populatedAllBranchesStaticRecord()); + AllBranchesStaticRecord result = client.getItem(schema, itemKey()); + + assertAllBranchesItem(client.storedItem()); + assertThat(result.getDirect()).isEqualTo(DIRECT_VALUE); + assertThat(result.getStatus()).isEqualTo(TestEnum.OPEN); + assertThat(result.getDetails().getNote()).isEqualTo(DETAILS_NOTE); + assertThat(result.getCounters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(result.getLabels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(result.getEvents()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Static immutable table put and get convert every default lookup type") + public void putItemThenGetItem_whenStaticImmutableSchemaHasAllLookupTypes_roundTripsConvertedValues( + DynamoDbEnhancedClientType clientType) { + SchemaTableClient client = openTable(clientType); + try { + TableSchema schema = allBranchesStaticImmutableSchema(); + + client.putItem(schema, populatedAllBranchesStaticImmutable()); + AllBranchesStaticImmutable result = client.getItem(schema, itemKey()); + + assertAllBranchesItem(client.storedItem()); + assertThat(result.direct()).isEqualTo(DIRECT_VALUE); + assertThat(result.status()).isEqualTo(TestEnum.OPEN); + assertThat(result.details().getNote()).isEqualTo(DETAILS_NOTE); + assertThat(result.counters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(result.labels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(result.events()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Document table put and get convert every default lookup type") + public void putItemThenGetItem_whenDocumentSchemaHasAllLookupTypes_roundTripsConvertedValues( + DynamoDbEnhancedClientType clientType) { + SchemaTableClient client = openTable(clientType); + try { + TableSchema details = detailsSchema(); + TableSchema schema = + TableSchema.documentSchemaBuilder() + .addIndexPartitionKey(TableMetadata.primaryIndexName(), "id", AttributeValueType.S) + .build(); + + client.putItem(schema, allBranchesDocument(details)); + EnhancedDocument result = client.getItem(schema, itemKey()); + + assertAllBranchesItem(client.storedItem()); + assertThat(result.get("direct", EnhancedType.of(String.class))).isEqualTo(DIRECT_VALUE); + assertThat(result.get("status", EnhancedType.of(TestEnum.class))).isEqualTo(TestEnum.OPEN); + assertThat(result.get("details", EnhancedType.documentOf(AllBranchesDetails.class, details)).getNote()) + .isEqualTo(DETAILS_NOTE); + assertThat(result.get("counters", EnhancedType.mapOf(String.class, Integer.class))) + .isInstanceOf(LinkedHashMap.class) + .isEqualTo(oneCounter()); + assertThat(result.get("labels", EnhancedType.setOf(String.class))) + .isInstanceOf(LinkedHashSet.class) + .isEqualTo(oneLabel()); + assertThat(result.get("events", EnhancedType.listOf(String.class))) + .isInstanceOf(ArrayList.class) + .isEqualTo(oneEvent()); + } finally { + client.deleteTable(); + } + } + + private SchemaTableClient openTable(DynamoDbEnhancedClientType clientType) { + SchemaTableClient client = clientType == DynamoDbEnhancedClientType.SYNC + ? new SyncSchemaTableClient() + : new AsyncSchemaTableClient(); + client.createTable(); + return client; + } + + private static Key itemKey() { + return Key.builder().partitionValue(ITEM_ID).build(); + } + + private static void assertWrittenCollections(Map map) { + assertThat(map.get("counters")).isEqualTo(countersAttribute()); + assertThat(map.get("labels")).isEqualTo(labelsAttribute()); + assertThat(map.get("events")).isEqualTo(eventsAttribute()); + } + + private static void assertAllBranchesItem(Map item) { + assertWrittenItem(item); + assertThat(item.get("direct").s()).isEqualTo(DIRECT_VALUE); + assertThat(item.get("status").s()).isEqualTo(TestEnum.OPEN.toString()); + assertThat(item.get("details").m().get("note").s()).isEqualTo(DETAILS_NOTE); + } + + private static void assertAllBranchesImmutable(AllBranchesImmutable result) { + assertThat(result.id()).isEqualTo(ITEM_ID); + assertThat(result.direct()).isEqualTo(DIRECT_VALUE); + assertThat(result.status()).isEqualTo(TestEnum.OPEN); + assertThat(result.details().getNote()).isEqualTo(DETAILS_NOTE); + assertThat(result.counters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(result.labels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(result.events()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + private static IndexMetadata index(TableSchema schema, String indexName) { + for (IndexMetadata index : schema.tableMetadata().indices()) { + if (indexName.equals(index.name())) { + return index; + } + } + throw new AssertionError("Missing index " + indexName); + } + + private interface SchemaTableClient { + void createTable(); + + void deleteTable(); + + void putItem(TableSchema schema, T item); + + T getItem(TableSchema schema, Key key); + + Map storedItem(); + } + + private final class SyncSchemaTableClient implements SchemaTableClient { + private final DynamoDbClient dynamoDbClient = localDynamoDb().createClient(); + private final DynamoDbEnhancedClient enhancedClient = + DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build(); + private final String tableName = getConcreteTableName("table-name"); + private final DynamoDbTable table = + enhancedClient.table(tableName, TableSchema.fromBean(ConverterRecord.class)); + + @Override + public void createTable() { + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @Override + public void deleteTable() { + dynamoDbClient.deleteTable(DeleteTableRequest.builder().tableName(tableName).build()); + } + + @Override + public void putItem(TableSchema schema, T item) { + enhancedClient.table(tableName, schema).putItem(item); + } + + @Override + public T getItem(TableSchema schema, Key key) { + return enhancedClient.table(tableName, schema).getItem(key); + } + + @Override + public Map storedItem() { + return dynamoDbClient.getItem(r -> r.tableName(tableName) + .key(Collections.singletonMap("id", AttributeValue.fromS(ITEM_ID))) + .consistentRead(true)) + .item(); + } + } + + private final class AsyncSchemaTableClient implements SchemaTableClient { + private final DynamoDbAsyncClient dynamoDbAsyncClient = localDynamoDb().createAsyncClient(); + private final DynamoDbEnhancedAsyncClient enhancedClient = + DynamoDbEnhancedAsyncClient.builder().dynamoDbClient(dynamoDbAsyncClient).build(); + private final String tableName = getConcreteTableName("table-name"); + private final DynamoDbAsyncTable table = + enhancedClient.table(tableName, TableSchema.fromBean(ConverterRecord.class)); + + @Override + public void createTable() { + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())).join(); + } + + @Override + public void deleteTable() { + dynamoDbAsyncClient.deleteTable(DeleteTableRequest.builder().tableName(tableName).build()).join(); + } + + @Override + public void putItem(TableSchema schema, T item) { + enhancedClient.table(tableName, schema).putItem(item).join(); + } + + @Override + public T getItem(TableSchema schema, Key key) { + return enhancedClient.table(tableName, schema).getItem(key).join(); + } + + @Override + public Map storedItem() { + return dynamoDbAsyncClient.getItem(r -> r.tableName(tableName) + .key(Collections.singletonMap("id", AttributeValue.fromS(ITEM_ID))) + .consistentRead(true)) + .join() + .item(); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderTableSetupTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderTableSetupTest.java new file mode 100644 index 000000000000..f63af5e60617 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DefaultAttributeConverterProviderTableSetupTest.java @@ -0,0 +1,386 @@ +/* + * 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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.staticImmutableRecordSchema; +import static software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.staticRecordSchema; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +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.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithArrayList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithHashMap; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithHashSet; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithObject; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.BeanWithUnsupported; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.ConverterImmutable; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.ConverterRecord; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.DefaultProviderBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.SingleGsiSortBean; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.DefaultAttributeConverterProviderTestModels.UnsupportedType; +import software.amazon.awssdk.enhanced.dynamodb.mapper.BeanTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.EnhancedGlobalSecondaryIndex; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest; +import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndexDescription; +import software.amazon.awssdk.services.dynamodb.model.ProjectionType; + +/** + * Enhanced Client table bind and createTable for DefaultAttributeConverterProvider schemas. + *

+ * Each case runs for {@link DynamoDbEnhancedClientType#SYNC} and {@link DynamoDbEnhancedClientType#ASYNC}. + * The class covers successful bind and create for all five schema kinds, bind failures for + * unconvertible attributes, omitted-provider bind, and GSI metadata on create. + */ +public class DefaultAttributeConverterProviderTableSetupTest extends LocalDynamoDbTestBase { + + @BeforeAll + public static void startLocalDynamoDb() { + localDynamoDb().start(); + } + + @AfterAll + public static void stopLocalDynamoDbForJunit5() { + localDynamoDb().stop(); + } + + @BeforeEach + public void clearBeanSchemaCache() throws Exception { + Method clear = BeanTableSchema.class.getDeclaredMethod("clearSchemaCache"); + clear.setAccessible(true); + clear.invoke(null); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Creates a table from a convertible bean schema") + public void tableThenCreateTable_whenBeanSchemaIsConvertible_createsTable(DynamoDbEnhancedClientType clientType) { + SetupClient client = openClient(clientType, "bean"); + try { + client.createTable(TableSchema.fromBean(ConverterRecord.class)); + + assertThat(client.tableExists()).isTrue(); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Creates a table from a convertible immutable schema") + public void tableThenCreateTable_whenImmutableSchemaIsConvertible_createsTable(DynamoDbEnhancedClientType clientType) { + SetupClient client = openClient(clientType, "immutable"); + try { + client.createTable(TableSchema.fromImmutableClass(ConverterImmutable.class)); + + assertThat(client.tableExists()).isTrue(); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Creates a table from a convertible static schema") + public void tableThenCreateTable_whenStaticSchemaIsConvertible_createsTable(DynamoDbEnhancedClientType clientType) { + SetupClient client = openClient(clientType, "static"); + try { + client.createTable(staticRecordSchema()); + + assertThat(client.tableExists()).isTrue(); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Creates a table from a convertible static immutable schema") + public void tableThenCreateTable_whenStaticImmutableSchemaIsConvertible_createsTable(DynamoDbEnhancedClientType clientType) { + SetupClient client = openClient(clientType, "static-imm"); + try { + client.createTable(staticImmutableRecordSchema()); + + assertThat(client.tableExists()).isTrue(); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Creates a table from a convertible document schema") + public void tableThenCreateTable_whenDocumentSchemaIsConvertible_createsTable(DynamoDbEnhancedClientType clientType) { + SetupClient client = openClient(clientType, "document"); + try { + client.createTable(documentSchema()); + + assertThat(client.tableExists()).isTrue(); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Creates a table when the bean omits converter providers") + public void tableThenCreateTable_whenConverterProvidersAreOmitted_createsTable(DynamoDbEnhancedClientType clientType) { + SetupClient client = openClient(clientType, "omitted"); + try { + client.createTable(TableSchema.fromBean(DefaultProviderBean.class)); + + assertThat(client.tableExists()).isTrue(); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Creates a GSI with an unspecified integer sort key") + public void tableThenCreateTable_whenBeanDeclaresGsi_createsIndexWithUnspecifiedIntegerSort( + DynamoDbEnhancedClientType clientType) { + SetupClient client = openClient(clientType, "gsi"); + try { + TableSchema schema = TableSchema.fromBean(SingleGsiSortBean.class); + + client.createTableWithGsi(schema, "gsi"); + + assertThat(schema.tableMetadata().indexPartitionKey("gsi")).isEqualTo("gsiKey"); + assertThat(schema.tableMetadata().indexSortKey("gsi")).contains("gsiSort"); + assertThat(client.globalSecondaryIndexNames()).contains("gsi"); + } finally { + client.deleteTable(); + } + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Rejects table setup when a bean Object attribute has no converter") + public void table_whenBeanDeclaresObject_throwsConverterNotFoundBeforeSetupCompletes( + DynamoDbEnhancedClientType clientType) { + assertThatThrownBy(() -> table(clientType, "unused-table", TableSchema.fromBean(BeanWithObject.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(Object.class)); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Rejects table setup when a bean attribute has no converter") + public void table_whenBeanDeclaresUnsupportedType_throwsIllegalStateException(DynamoDbEnhancedClientType clientType) { + assertThatThrownBy(() -> table(clientType, "unused-table", TableSchema.fromBean(BeanWithUnsupported.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + EnhancedType.of(UnsupportedType.class)); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Rejects table setup when a bean attribute is ArrayList") + public void table_whenBeanDeclaresArrayList_throwsIllegalStateException(DynamoDbEnhancedClientType clientType) { + EnhancedType> type = new EnhancedType>() { }; + assertThatThrownBy(() -> table(clientType, "unused-table", TableSchema.fromBean(BeanWithArrayList.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Rejects table setup when a bean attribute is HashSet") + public void table_whenBeanDeclaresHashSet_throwsIllegalStateException(DynamoDbEnhancedClientType clientType) { + EnhancedType> type = new EnhancedType>() { }; + assertThatThrownBy(() -> table(clientType, "unused-table", TableSchema.fromBean(BeanWithHashSet.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(DynamoDbEnhancedClientType.class) + @DisplayName("Rejects table setup when a bean attribute is HashMap") + public void table_whenBeanDeclaresHashMap_throwsIllegalStateException(DynamoDbEnhancedClientType clientType) { + EnhancedType> type = new EnhancedType>() { }; + assertThatThrownBy(() -> table(clientType, "unused-table", TableSchema.fromBean(BeanWithHashMap.class))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Converter not found for " + type); + } + + private SetupClient openClient(DynamoDbEnhancedClientType clientType, String tableNameSuffix) { + return clientType == DynamoDbEnhancedClientType.SYNC + ? new SyncSetupClient(tableNameSuffix) + : new AsyncSetupClient(tableNameSuffix); + } + + private void table(DynamoDbEnhancedClientType clientType, String tableName, TableSchema schema) { + if (clientType == DynamoDbEnhancedClientType.SYNC) { + DynamoDbEnhancedClient.builder() + .dynamoDbClient(localDynamoDb().createClient()) + .build() + .table(tableName, schema); + return; + } + DynamoDbEnhancedAsyncClient.builder() + .dynamoDbClient(localDynamoDb().createAsyncClient()) + .build() + .table(tableName, schema); + } + + private static TableSchema documentSchema() { + return TableSchema.documentSchemaBuilder() + .addIndexPartitionKey(TableMetadata.primaryIndexName(), "id", AttributeValueType.S) + .build(); + } + + private interface SetupClient { + void createTable(TableSchema schema); + + void createTableWithGsi(TableSchema schema, String indexName); + + boolean tableExists(); + + List globalSecondaryIndexNames(); + + void deleteTable(); + } + + private final class SyncSetupClient implements SetupClient { + private final DynamoDbClient dynamoDbClient = localDynamoDb().createClient(); + private final DynamoDbEnhancedClient enhancedClient = + DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build(); + private final String tableName; + + SyncSetupClient(String tableNameSuffix) { + this.tableName = getConcreteTableName(tableNameSuffix); + } + + @Override + public void createTable(TableSchema schema) { + DynamoDbTable table = enhancedClient.table(tableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())); + } + + @Override + public void createTableWithGsi(TableSchema schema, String indexName) { + DynamoDbTable table = enhancedClient.table(tableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()) + .globalSecondaryIndices(EnhancedGlobalSecondaryIndex.builder() + .indexName(indexName) + .projection(p -> p.projectionType( + ProjectionType.KEYS_ONLY)) + .provisionedThroughput( + getDefaultProvisionedThroughput()) + .build())); + } + + @Override + public boolean tableExists() { + return tableName.equals(dynamoDbClient.describeTable(r -> r.tableName(tableName)).table().tableName()); + } + + @Override + public List globalSecondaryIndexNames() { + List indexes = + dynamoDbClient.describeTable(r -> r.tableName(tableName)).table().globalSecondaryIndexes(); + if (indexes == null) { + return Collections.emptyList(); + } + List names = new ArrayList<>(); + for (GlobalSecondaryIndexDescription index : indexes) { + names.add(index.indexName()); + } + return names; + } + + @Override + public void deleteTable() { + dynamoDbClient.deleteTable(DeleteTableRequest.builder().tableName(tableName).build()); + } + } + + private final class AsyncSetupClient implements SetupClient { + private final DynamoDbAsyncClient dynamoDbAsyncClient = localDynamoDb().createAsyncClient(); + private final DynamoDbEnhancedAsyncClient enhancedClient = + DynamoDbEnhancedAsyncClient.builder().dynamoDbClient(dynamoDbAsyncClient).build(); + private final String tableName; + + AsyncSetupClient(String tableNameSuffix) { + this.tableName = getConcreteTableName(tableNameSuffix); + } + + @Override + public void createTable(TableSchema schema) { + DynamoDbAsyncTable table = enhancedClient.table(tableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput())).join(); + } + + @Override + public void createTableWithGsi(TableSchema schema, String indexName) { + DynamoDbAsyncTable table = enhancedClient.table(tableName, schema); + table.createTable(r -> r.provisionedThroughput(getDefaultProvisionedThroughput()) + .globalSecondaryIndices(EnhancedGlobalSecondaryIndex.builder() + .indexName(indexName) + .projection(p -> p.projectionType( + ProjectionType.KEYS_ONLY)) + .provisionedThroughput( + getDefaultProvisionedThroughput()) + .build())) + .join(); + } + + @Override + public boolean tableExists() { + return tableName.equals(dynamoDbAsyncClient.describeTable(r -> r.tableName(tableName)).join().table().tableName()); + } + + @Override + public List globalSecondaryIndexNames() { + List indexes = + dynamoDbAsyncClient.describeTable(r -> r.tableName(tableName)).join().table().globalSecondaryIndexes(); + if (indexes == null) { + return Collections.emptyList(); + } + List names = new ArrayList<>(); + for (GlobalSecondaryIndexDescription index : indexes) { + names.add(index.indexName()); + } + return names; + } + + @Override + public void deleteTable() { + dynamoDbAsyncClient.deleteTable(DeleteTableRequest.builder().tableName(tableName).build()).join(); + } + } +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DynamoDbEnhancedClientType.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DynamoDbEnhancedClientType.java new file mode 100644 index 000000000000..a423ec59a5c0 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/DynamoDbEnhancedClientType.java @@ -0,0 +1,25 @@ +/* + * 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; + +/** + * Identifies the DynamoDB Enhanced Client mode used by parameterized table operation tests. The values distinguish + * operations performed through synchronous clients from operations performed through asynchronous clients. + */ +public enum DynamoDbEnhancedClientType { + SYNC, + ASYNC +} diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/DefaultAttributeConverterProviderTestModels.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/DefaultAttributeConverterProviderTestModels.java new file mode 100644 index 000000000000..8467658b4663 --- /dev/null +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/DefaultAttributeConverterProviderTestModels.java @@ -0,0 +1,1339 @@ +/* + * 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.models; + +import static org.assertj.core.api.Assertions.assertThat; +import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbFlatten; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; + +/** + * Shared models, collection fixtures, and DynamoDB attribute maps for DefaultAttributeConverterProvider table tests. + */ +public final class DefaultAttributeConverterProviderTestModels { + + public static final String TABLE_NAME = "TABLE_NAME"; + public static final String ITEM_ID = "id-1"; + public static final String COUNTER_KEY = "count"; + public static final Integer COUNTER_VALUE = 1; + public static final String LABEL_VALUE = "red"; + public static final String EVENT_VALUE = "created"; + public static final String DIRECT_VALUE = "plain"; + public static final String DETAILS_NOTE = "note-1"; + + private DefaultAttributeConverterProviderTestModels() { + } + + public static LinkedHashMap oneCounter() { + LinkedHashMap counters = new LinkedHashMap<>(); + counters.put(COUNTER_KEY, COUNTER_VALUE); + return counters; + } + + public static LinkedHashSet oneLabel() { + LinkedHashSet labels = new LinkedHashSet<>(); + labels.add(LABEL_VALUE); + return labels; + } + + public static ArrayList oneEvent() { + ArrayList events = new ArrayList<>(); + events.add(EVENT_VALUE); + return events; + } + + public static AttributeValue idAttribute() { + return AttributeValue.builder().s(ITEM_ID).build(); + } + + public static AttributeValue countersAttribute() { + return AttributeValue.builder() + .m(Collections.singletonMap(COUNTER_KEY, + AttributeValue.builder().n("1").build())) + .build(); + } + + public static AttributeValue labelsAttribute() { + return AttributeValue.builder().ss(LABEL_VALUE).build(); + } + + public static AttributeValue eventsAttribute() { + return AttributeValue.builder() + .l(AttributeValue.builder().s(EVENT_VALUE).build()) + .build(); + } + + public static AttributeValue eventsWithNullElementAttribute() { + List members = new ArrayList<>(); + members.add(AttributeValue.builder().s("a").build()); + members.add(AttributeValue.fromNul(true)); + members.add(AttributeValue.builder().s("b").build()); + return AttributeValue.builder().l(members).build(); + } + + public static Map completeItem() { + Map item = new LinkedHashMap<>(); + item.put("id", idAttribute()); + item.put("counters", countersAttribute()); + item.put("labels", labelsAttribute()); + item.put("events", eventsAttribute()); + return item; + } + + public static Map completeItemWithNullableNull() { + Map item = new LinkedHashMap<>(completeItem()); + item.put("nullable", AttributeValue.fromNul(true)); + return item; + } + + public static ConverterRecord populatedConverterRecord() { + ConverterRecord record = new ConverterRecord(); + record.setId(ITEM_ID); + record.setCounters(oneCounter()); + record.setLabels(oneLabel()); + record.setEvents(oneEvent()); + return record; + } + + public static void assertWrittenItem(Map item) { + assertThat(item.get("id").s()).isEqualTo(ITEM_ID); + assertThat(item.get("counters")).isEqualTo(countersAttribute()); + assertThat(item.get("labels")).isEqualTo(labelsAttribute()); + assertThat(item.get("events")).isEqualTo(eventsAttribute()); + } + + public static void assertReconstructedCollections(ConverterRecord record) { + assertThat(record.getId()).isEqualTo(ITEM_ID); + assertThat(record.getCounters()).isInstanceOf(LinkedHashMap.class).isEqualTo(oneCounter()); + assertThat(record.getLabels()).isInstanceOf(LinkedHashSet.class).isEqualTo(oneLabel()); + assertThat(record.getEvents()).isInstanceOf(ArrayList.class).isEqualTo(oneEvent()); + } + + public static ConverterImmutable populatedConverterImmutable() { + return ConverterImmutable.builder() + .id(ITEM_ID) + .counters(oneCounter()) + .labels(oneLabel()) + .events(oneEvent()) + .build(); + } + + public static StaticRecord populatedStaticRecord() { + StaticRecord record = new StaticRecord(); + record.setId(ITEM_ID); + record.setCounters(oneCounter()); + record.setLabels(oneLabel()); + record.setEvents(oneEvent()); + return record; + } + + public static StaticImmutableRecord populatedStaticImmutableRecord() { + return StaticImmutableRecord.builder() + .id(ITEM_ID) + .counters(oneCounter()) + .labels(oneLabel()) + .events(oneEvent()) + .build(); + } + + public static DefaultProviderBean populatedDefaultProviderBean() { + DefaultProviderBean bean = new DefaultProviderBean(); + bean.setId(ITEM_ID); + bean.setCounters(oneCounter()); + bean.setLabels(oneLabel()); + bean.setEvents(oneEvent()); + return bean; + } + + public static DefaultProviderImmutable populatedDefaultProviderImmutable() { + return DefaultProviderImmutable.builder() + .id(ITEM_ID) + .counters(oneCounter()) + .labels(oneLabel()) + .events(oneEvent()) + .build(); + } + + public static AllBranchesDetails populatedDetails() { + AllBranchesDetails details = new AllBranchesDetails(); + details.setNote(DETAILS_NOTE); + return details; + } + + public static TableSchema detailsSchema() { + return TableSchema.fromBean(AllBranchesDetails.class); + } + + public static AllBranchesBean populatedAllBranchesBean() { + AllBranchesBean bean = new AllBranchesBean(); + bean.setId(ITEM_ID); + bean.setDirect(DIRECT_VALUE); + bean.setCounters(oneCounter()); + bean.setLabels(oneLabel()); + bean.setEvents(oneEvent()); + bean.setStatus(TestEnum.OPEN); + bean.setDetails(populatedDetails()); + return bean; + } + + public static AllBranchesImmutable populatedAllBranchesImmutable() { + return AllBranchesImmutable.builder() + .id(ITEM_ID) + .direct(DIRECT_VALUE) + .counters(oneCounter()) + .labels(oneLabel()) + .events(oneEvent()) + .status(TestEnum.OPEN) + .details(populatedDetails()) + .build(); + } + + public static AllBranchesStaticRecord populatedAllBranchesStaticRecord() { + AllBranchesStaticRecord record = new AllBranchesStaticRecord(); + record.setId(ITEM_ID); + record.setDirect(DIRECT_VALUE); + record.setCounters(oneCounter()); + record.setLabels(oneLabel()); + record.setEvents(oneEvent()); + record.setStatus(TestEnum.OPEN); + record.setDetails(populatedDetails()); + return record; + } + + public static AllBranchesStaticImmutable populatedAllBranchesStaticImmutable() { + return AllBranchesStaticImmutable.builder() + .id(ITEM_ID) + .direct(DIRECT_VALUE) + .counters(oneCounter()) + .labels(oneLabel()) + .events(oneEvent()) + .status(TestEnum.OPEN) + .details(populatedDetails()) + .build(); + } + + public static Map allBranchesItem() { + Map details = new LinkedHashMap<>(); + details.put("note", AttributeValue.builder().s(DETAILS_NOTE).build()); + Map item = new LinkedHashMap<>(completeItem()); + item.put("direct", AttributeValue.builder().s(DIRECT_VALUE).build()); + item.put("status", AttributeValue.builder().s(TestEnum.OPEN.toString()).build()); + item.put("details", AttributeValue.builder().m(details).build()); + return item; + } + + public static EnhancedDocument typedCollectionsDocument() { + return EnhancedDocument.builder() + .putString("id", ITEM_ID) + .putMap("counters", oneCounter(), + EnhancedType.of(String.class), EnhancedType.of(Integer.class)) + .put("labels", oneLabel(), EnhancedType.setOf(String.class)) + .putList("events", oneEvent(), EnhancedType.of(String.class)) + .build(); + } + + public static EnhancedDocument typedCollectionsDocumentWithNull() { + return typedCollectionsDocument().toBuilder() + .putNull("nullable") + .build(); + } + + public static EnhancedDocument allBranchesDocument(TableSchema schema) { + return EnhancedDocument.builder() + .putString("id", ITEM_ID) + .putString("direct", DIRECT_VALUE) + .putMap("counters", oneCounter(), + EnhancedType.of(String.class), EnhancedType.of(Integer.class)) + .put("labels", oneLabel(), EnhancedType.setOf(String.class)) + .putList("events", oneEvent(), EnhancedType.of(String.class)) + .put("status", TestEnum.OPEN, TestEnum.class) + .put("details", populatedDetails(), + EnhancedType.documentOf(AllBranchesDetails.class, schema)) + .build(); + } + + public static StaticTableSchema staticRecordSchema() { + return StaticTableSchema.builder(StaticRecord.class) + .newItemSupplier(StaticRecord::new) + .addAttribute(String.class, a -> a.name("id") + .getter(StaticRecord::getId) + .setter(StaticRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.mapOf(String.class, Integer.class), + a -> a.name("counters") + .getter(StaticRecord::getCounters) + .setter(StaticRecord::setCounters)) + .addAttribute(EnhancedType.setOf(String.class), + a -> a.name("labels") + .getter(StaticRecord::getLabels) + .setter(StaticRecord::setLabels)) + .addAttribute(EnhancedType.listOf(String.class), + a -> a.name("events") + .getter(StaticRecord::getEvents) + .setter(StaticRecord::setEvents)) + .build(); + } + + public static StaticImmutableTableSchema + staticImmutableRecordSchema() { + return StaticImmutableTableSchema.builder(StaticImmutableRecord.class, StaticImmutableRecord.Builder.class) + .newItemBuilder(StaticImmutableRecord::builder, + StaticImmutableRecord.Builder::build) + .addAttribute(String.class, a -> a.name("id") + .getter(StaticImmutableRecord::id) + .setter(StaticImmutableRecord.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.mapOf(String.class, Integer.class), + a -> a.name("counters") + .getter(StaticImmutableRecord::counters) + .setter(StaticImmutableRecord.Builder::counters)) + .addAttribute(EnhancedType.setOf(String.class), + a -> a.name("labels") + .getter(StaticImmutableRecord::labels) + .setter(StaticImmutableRecord.Builder::labels)) + .addAttribute(EnhancedType.listOf(String.class), + a -> a.name("events") + .getter(StaticImmutableRecord::events) + .setter(StaticImmutableRecord.Builder::events)) + .build(); + } + + public static StaticTableSchema allBranchesStaticSchema() { + TableSchema details = detailsSchema(); + return StaticTableSchema.builder(AllBranchesStaticRecord.class) + .newItemSupplier(AllBranchesStaticRecord::new) + .addAttribute(EnhancedType.of(String.class), + a -> a.name("id") + .getter(AllBranchesStaticRecord::getId) + .setter(AllBranchesStaticRecord::setId) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.of(String.class), + a -> a.name("direct") + .getter(AllBranchesStaticRecord::getDirect) + .setter(AllBranchesStaticRecord::setDirect)) + .addAttribute(EnhancedType.mapOf(String.class, Integer.class), + a -> a.name("counters") + .getter(AllBranchesStaticRecord::getCounters) + .setter(AllBranchesStaticRecord::setCounters)) + .addAttribute(EnhancedType.setOf(String.class), + a -> a.name("labels") + .getter(AllBranchesStaticRecord::getLabels) + .setter(AllBranchesStaticRecord::setLabels)) + .addAttribute(EnhancedType.listOf(String.class), + a -> a.name("events") + .getter(AllBranchesStaticRecord::getEvents) + .setter(AllBranchesStaticRecord::setEvents)) + .addAttribute(EnhancedType.of(TestEnum.class), + a -> a.name("status") + .getter(AllBranchesStaticRecord::getStatus) + .setter(AllBranchesStaticRecord::setStatus)) + .addAttribute(EnhancedType.documentOf(AllBranchesDetails.class, details), + a -> a.name("details") + .getter(AllBranchesStaticRecord::getDetails) + .setter(AllBranchesStaticRecord::setDetails)) + .build(); + } + + public static StaticImmutableTableSchema + allBranchesStaticImmutableSchema() { + TableSchema details = detailsSchema(); + return StaticImmutableTableSchema.builder(AllBranchesStaticImmutable.class, + AllBranchesStaticImmutable.Builder.class) + .newItemBuilder(AllBranchesStaticImmutable::builder, + AllBranchesStaticImmutable.Builder::build) + .addAttribute(EnhancedType.of(String.class), + a -> a.name("id") + .getter(AllBranchesStaticImmutable::id) + .setter(AllBranchesStaticImmutable.Builder::id) + .tags(primaryPartitionKey())) + .addAttribute(EnhancedType.of(String.class), + a -> a.name("direct") + .getter(AllBranchesStaticImmutable::direct) + .setter(AllBranchesStaticImmutable.Builder::direct)) + .addAttribute(EnhancedType.mapOf(String.class, Integer.class), + a -> a.name("counters") + .getter(AllBranchesStaticImmutable::counters) + .setter(AllBranchesStaticImmutable.Builder::counters)) + .addAttribute(EnhancedType.setOf(String.class), + a -> a.name("labels") + .getter(AllBranchesStaticImmutable::labels) + .setter(AllBranchesStaticImmutable.Builder::labels)) + .addAttribute(EnhancedType.listOf(String.class), + a -> a.name("events") + .getter(AllBranchesStaticImmutable::events) + .setter(AllBranchesStaticImmutable.Builder::events)) + .addAttribute(EnhancedType.of(TestEnum.class), + a -> a.name("status") + .getter(AllBranchesStaticImmutable::status) + .setter(AllBranchesStaticImmutable.Builder::status)) + .addAttribute(EnhancedType.documentOf(AllBranchesDetails.class, details), + a -> a.name("details") + .getter(AllBranchesStaticImmutable::details) + .setter(AllBranchesStaticImmutable.Builder::details)) + .build(); + } + + public enum TestEnum { + OPEN, + CLOSED + } + + public static class UnsupportedType { + } + + @DynamoDbBean + public static class ConverterRecord { + private String id; + private Map counters; + private Set labels; + private List events; + private String nullable; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + public String getNullable() { + return nullable; + } + + public void setNullable(String nullable) { + this.nullable = nullable; + } + } + + @DynamoDbImmutable(builder = ConverterImmutable.Builder.class) + public static final class ConverterImmutable { + private final String id; + private final Map counters; + private final Set labels; + private final List events; + + private ConverterImmutable(Builder builder) { + this.id = builder.id; + this.counters = builder.counters; + this.labels = builder.labels; + this.events = builder.events; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public Map counters() { + return counters; + } + + public Set labels() { + return labels; + } + + public List events() { + return events; + } + + public static final class Builder { + private String id; + private Map counters; + private Set labels; + private List events; + + private Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder counters(Map counters) { + this.counters = counters; + return this; + } + + public Builder labels(Set labels) { + this.labels = labels; + return this; + } + + public Builder events(List events) { + this.events = events; + return this; + } + + public ConverterImmutable build() { + return new ConverterImmutable(this); + } + } + } + + public static class StaticRecord { + private String id; + private Map counters; + private Set labels; + private List events; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + } + + public static final class StaticImmutableRecord { + private final String id; + private final Map counters; + private final Set labels; + private final List events; + + private StaticImmutableRecord(Builder builder) { + this.id = builder.id; + this.counters = builder.counters; + this.labels = builder.labels; + this.events = builder.events; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public Map counters() { + return counters; + } + + public Set labels() { + return labels; + } + + public List events() { + return events; + } + + public static final class Builder { + private String id; + private Map counters; + private Set labels; + private List events; + + private Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder counters(Map counters) { + this.counters = counters; + return this; + } + + public Builder labels(Set labels) { + this.labels = labels; + return this; + } + + public Builder events(List events) { + this.events = events; + return this; + } + + public StaticImmutableRecord build() { + return new StaticImmutableRecord(this); + } + } + } + + @DynamoDbBean + public static class DefaultProviderBean { + private String id; + private Map counters; + private Set labels; + private List events; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + } + + @DynamoDbImmutable(builder = DefaultProviderImmutable.Builder.class) + public static final class DefaultProviderImmutable { + private final String id; + private final Map counters; + private final Set labels; + private final List events; + + private DefaultProviderImmutable(Builder builder) { + this.id = builder.id; + this.counters = builder.counters; + this.labels = builder.labels; + this.events = builder.events; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public Map counters() { + return counters; + } + + public Set labels() { + return labels; + } + + public List events() { + return events; + } + + public static final class Builder { + private String id; + private Map counters; + private Set labels; + private List events; + + private Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder counters(Map counters) { + this.counters = counters; + return this; + } + + public Builder labels(Set labels) { + this.labels = labels; + return this; + } + + public Builder events(List events) { + this.events = events; + return this; + } + + public DefaultProviderImmutable build() { + return new DefaultProviderImmutable(this); + } + } + } + + @DynamoDbBean + public static class FlattenedChildBean { + private Map counters; + private Set labels; + private List events; + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + } + + @DynamoDbBean + public static class OuterFlattenedBean { + private String id; + private FlattenedChildBean child; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbFlatten + public FlattenedChildBean getChild() { + return child; + } + + public void setChild(FlattenedChildBean child) { + this.child = child; + } + } + + @DynamoDbBean + public static class SingleGsiPartitionBean { + private String id; + private String gsiKey; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "gsi") + public String getGsiKey() { + return gsiKey; + } + + public void setGsiKey(String gsiKey) { + this.gsiKey = gsiKey; + } + } + + @DynamoDbBean + public static class SingleGsiSortBean { + private String id; + private String gsiKey; + private Integer gsiSort; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @DynamoDbSecondaryPartitionKey(indexNames = "gsi") + public String getGsiKey() { + return gsiKey; + } + + public void setGsiKey(String gsiKey) { + this.gsiKey = gsiKey; + } + + @DynamoDbSecondarySortKey(indexNames = "gsi") + public Integer getGsiSort() { + return gsiSort; + } + + public void setGsiSort(Integer gsiSort) { + this.gsiSort = gsiSort; + } + } + + @DynamoDbBean + public static class AllBranchesDetails { + private String note; + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + } + + @DynamoDbBean + public static class AllBranchesBean { + private String id; + private String direct; + private Map counters; + private Set labels; + private List events; + private TestEnum status; + private AllBranchesDetails details; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDirect() { + return direct; + } + + public void setDirect(String direct) { + this.direct = direct; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + public TestEnum getStatus() { + return status; + } + + public void setStatus(TestEnum status) { + this.status = status; + } + + public AllBranchesDetails getDetails() { + return details; + } + + public void setDetails(AllBranchesDetails details) { + this.details = details; + } + } + + @DynamoDbImmutable(builder = AllBranchesImmutable.Builder.class) + public static final class AllBranchesImmutable { + private final String id; + private final String direct; + private final Map counters; + private final Set labels; + private final List events; + private final TestEnum status; + private final AllBranchesDetails details; + + private AllBranchesImmutable(Builder builder) { + this.id = builder.id; + this.direct = builder.direct; + this.counters = builder.counters; + this.labels = builder.labels; + this.events = builder.events; + this.status = builder.status; + this.details = builder.details; + } + + public static Builder builder() { + return new Builder(); + } + + @DynamoDbPartitionKey + public String id() { + return id; + } + + public String direct() { + return direct; + } + + public Map counters() { + return counters; + } + + public Set labels() { + return labels; + } + + public List events() { + return events; + } + + public TestEnum status() { + return status; + } + + public AllBranchesDetails details() { + return details; + } + + public static final class Builder { + private String id; + private String direct; + private Map counters; + private Set labels; + private List events; + private TestEnum status; + private AllBranchesDetails details; + + private Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder direct(String direct) { + this.direct = direct; + return this; + } + + public Builder counters(Map counters) { + this.counters = counters; + return this; + } + + public Builder labels(Set labels) { + this.labels = labels; + return this; + } + + public Builder events(List events) { + this.events = events; + return this; + } + + public Builder status(TestEnum status) { + this.status = status; + return this; + } + + public Builder details(AllBranchesDetails details) { + this.details = details; + return this; + } + + public AllBranchesImmutable build() { + return new AllBranchesImmutable(this); + } + } + } + + public static class AllBranchesStaticRecord { + private String id; + private String direct; + private Map counters; + private Set labels; + private List events; + private TestEnum status; + private AllBranchesDetails details; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDirect() { + return direct; + } + + public void setDirect(String direct) { + this.direct = direct; + } + + public Map getCounters() { + return counters; + } + + public void setCounters(Map counters) { + this.counters = counters; + } + + public Set getLabels() { + return labels; + } + + public void setLabels(Set labels) { + this.labels = labels; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + public TestEnum getStatus() { + return status; + } + + public void setStatus(TestEnum status) { + this.status = status; + } + + public AllBranchesDetails getDetails() { + return details; + } + + public void setDetails(AllBranchesDetails details) { + this.details = details; + } + } + + public static final class AllBranchesStaticImmutable { + private final String id; + private final String direct; + private final Map counters; + private final Set labels; + private final List events; + private final TestEnum status; + private final AllBranchesDetails details; + + private AllBranchesStaticImmutable(Builder builder) { + this.id = builder.id; + this.direct = builder.direct; + this.counters = builder.counters; + this.labels = builder.labels; + this.events = builder.events; + this.status = builder.status; + this.details = builder.details; + } + + public static Builder builder() { + return new Builder(); + } + + public String id() { + return id; + } + + public String direct() { + return direct; + } + + public Map counters() { + return counters; + } + + public Set labels() { + return labels; + } + + public List events() { + return events; + } + + public TestEnum status() { + return status; + } + + public AllBranchesDetails details() { + return details; + } + + public static final class Builder { + private String id; + private String direct; + private Map counters; + private Set labels; + private List events; + private TestEnum status; + private AllBranchesDetails details; + + private Builder() { + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder direct(String direct) { + this.direct = direct; + return this; + } + + public Builder counters(Map counters) { + this.counters = counters; + return this; + } + + public Builder labels(Set labels) { + this.labels = labels; + return this; + } + + public Builder events(List events) { + this.events = events; + return this; + } + + public Builder status(TestEnum status) { + this.status = status; + return this; + } + + public Builder details(AllBranchesDetails details) { + this.details = details; + return this; + } + + public AllBranchesStaticImmutable build() { + return new AllBranchesStaticImmutable(this); + } + } + } + + @DynamoDbBean + public static class BeanWithObject { + private String id; + private Object payload; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Object getPayload() { + return payload; + } + + public void setPayload(Object payload) { + this.payload = payload; + } + } + + @DynamoDbBean + public static class BeanWithUnsupported { + private String id; + private UnsupportedType payload; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public UnsupportedType getPayload() { + return payload; + } + + public void setPayload(UnsupportedType payload) { + this.payload = payload; + } + } + + @DynamoDbBean + public static class BeanWithArrayList { + private String id; + private ArrayList values; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public ArrayList getValues() { + return values; + } + + public void setValues(ArrayList values) { + this.values = values; + } + } + + @DynamoDbBean + public static class BeanWithHashSet { + private String id; + private HashSet values; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public HashSet getValues() { + return values; + } + + public void setValues(HashSet values) { + this.values = values; + } + } + + @DynamoDbBean + public static class BeanWithHashMap { + private String id; + private HashMap values; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public HashMap getValues() { + return values; + } + + public void setValues(HashMap values) { + this.values = values; + } + } +}