diff --git a/integration-tests/src/main/java/com/github/wassertim/dynamodb/toolkit/integration/entities/RouteInstruction.java b/integration-tests/src/main/java/com/github/wassertim/dynamodb/toolkit/integration/entities/RouteInstruction.java new file mode 100644 index 0000000..5b0108e --- /dev/null +++ b/integration-tests/src/main/java/com/github/wassertim/dynamodb/toolkit/integration/entities/RouteInstruction.java @@ -0,0 +1,24 @@ +package com.github.wassertim.dynamodb.toolkit.integration.entities; + +import com.github.wassertim.dynamodb.toolkit.api.annotations.DynamoMappable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@DynamoMappable +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RouteInstruction { + private String text; + private Double distance; + private Double duration; + private String type; + + // This field causes the generation error + private List waypointIndices; +} diff --git a/integration-tests/src/test/java/com/github/wassertim/dynamodb/toolkit/integration/ListIntegerMappingTest.java b/integration-tests/src/test/java/com/github/wassertim/dynamodb/toolkit/integration/ListIntegerMappingTest.java new file mode 100644 index 0000000..89cf6bb --- /dev/null +++ b/integration-tests/src/test/java/com/github/wassertim/dynamodb/toolkit/integration/ListIntegerMappingTest.java @@ -0,0 +1,129 @@ +package com.github.wassertim.dynamodb.toolkit.integration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import static org.assertj.core.api.Assertions.*; + +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import com.github.wassertim.dynamodb.toolkit.integration.entities.RouteInstruction; +import com.github.wassertim.dynamodb.toolkit.mappers.RouteInstructionMapper; + +import java.util.Arrays; +import java.util.Map; + +/** + * Integration test to verify that the DynamoDB Toolkit correctly handles + * List fields in @DynamoMappable entities. + */ +public class ListIntegerMappingTest { + + private RouteInstruction routeInstruction; + private RouteInstructionMapper mapper; + + @BeforeEach + void setUp() { + routeInstruction = RouteInstruction.builder() + .text("Turn left on Main Street") + .distance(150.5) + .duration(45.0) + .type("turn") + .waypointIndices(Arrays.asList(0, 3, 7, 12)) + .build(); + + mapper = new RouteInstructionMapper(); + } + + @Test + @DisplayName("List field should be correctly mapped to DynamoDB") + void testListIntegerMapping() { + // Convert to DynamoDB AttributeValue + AttributeValue attributeValue = mapper.toDynamoDbAttributeValue(routeInstruction); + + assertThat(attributeValue).isNotNull(); + assertThat(attributeValue.hasM()).isTrue(); + + Map map = attributeValue.m(); + + // Verify all fields are present + assertThat(map).containsKey("text"); + assertThat(map).containsKey("distance"); + assertThat(map).containsKey("duration"); + assertThat(map).containsKey("type"); + assertThat(map).containsKey("waypointIndices"); + + // Verify waypointIndices is a list + AttributeValue waypointIndicesValue = map.get("waypointIndices"); + assertThat(waypointIndicesValue.hasL()).isTrue(); + assertThat(waypointIndicesValue.l()).hasSize(4); + + // Verify each element is a number + assertThat(waypointIndicesValue.l().get(0).n()).isEqualTo("0"); + assertThat(waypointIndicesValue.l().get(1).n()).isEqualTo("3"); + assertThat(waypointIndicesValue.l().get(2).n()).isEqualTo("7"); + assertThat(waypointIndicesValue.l().get(3).n()).isEqualTo("12"); + } + + @Test + @DisplayName("List field should support round-trip conversion") + void testListIntegerRoundTrip() { + // Convert to DynamoDB and back + AttributeValue attributeValue = mapper.toDynamoDbAttributeValue(routeInstruction); + RouteInstruction converted = mapper.fromDynamoDbAttributeValue(attributeValue); + + assertThat(converted).isNotNull(); + assertThat(converted.getText()).isEqualTo(routeInstruction.getText()); + assertThat(converted.getDistance()).isEqualTo(routeInstruction.getDistance()); + assertThat(converted.getDuration()).isEqualTo(routeInstruction.getDuration()); + assertThat(converted.getType()).isEqualTo(routeInstruction.getType()); + assertThat(converted.getWaypointIndices()).isEqualTo(routeInstruction.getWaypointIndices()); + } + + @Test + @DisplayName("Null List field should be handled correctly") + void testNullListInteger() { + RouteInstruction withNullList = RouteInstruction.builder() + .text("Continue straight") + .distance(200.0) + .duration(60.0) + .type("straight") + .waypointIndices(null) + .build(); + + AttributeValue attributeValue = mapper.toDynamoDbAttributeValue(withNullList); + assertThat(attributeValue).isNotNull(); + + Map map = attributeValue.m(); + assertThat(map).doesNotContainKey("waypointIndices"); + + // Round-trip conversion + RouteInstruction converted = mapper.fromDynamoDbAttributeValue(attributeValue); + assertThat(converted.getWaypointIndices()).isNull(); + } + + @Test + @DisplayName("Empty List field should be handled correctly") + void testEmptyListInteger() { + RouteInstruction withEmptyList = RouteInstruction.builder() + .text("Arrive at destination") + .distance(0.0) + .duration(0.0) + .type("arrive") + .waypointIndices(Arrays.asList()) + .build(); + + AttributeValue attributeValue = mapper.toDynamoDbAttributeValue(withEmptyList); + assertThat(attributeValue).isNotNull(); + + Map map = attributeValue.m(); + assertThat(map).containsKey("waypointIndices"); + + AttributeValue waypointIndicesValue = map.get("waypointIndices"); + assertThat(waypointIndicesValue.hasL()).isTrue(); + assertThat(waypointIndicesValue.l()).isEmpty(); + + // Round-trip conversion + RouteInstruction converted = mapper.fromDynamoDbAttributeValue(attributeValue); + assertThat(converted.getWaypointIndices()).isEmpty(); + } +} diff --git a/src/main/java/com/github/wassertim/dynamodb/toolkit/analysis/FieldInfo.java b/src/main/java/com/github/wassertim/dynamodb/toolkit/analysis/FieldInfo.java index 234fdc0..e0e6e91 100644 --- a/src/main/java/com/github/wassertim/dynamodb/toolkit/analysis/FieldInfo.java +++ b/src/main/java/com/github/wassertim/dynamodb/toolkit/analysis/FieldInfo.java @@ -72,6 +72,7 @@ public enum MappingStrategy { INSTANT, // Instant timestamp mapping ENUM, // Enum name mapping STRING_LIST, // List mapping + NUMBER_LIST, // List, List, etc. mapping NESTED_NUMBER_LIST, // List> mapping for coordinates COMPLEX_OBJECT, // Nested object requiring mapper COMPLEX_LIST, // List requiring mapper diff --git a/src/main/java/com/github/wassertim/dynamodb/toolkit/analysis/TypeAnalyzer.java b/src/main/java/com/github/wassertim/dynamodb/toolkit/analysis/TypeAnalyzer.java index a53872e..9343f5d 100644 --- a/src/main/java/com/github/wassertim/dynamodb/toolkit/analysis/TypeAnalyzer.java +++ b/src/main/java/com/github/wassertim/dynamodb/toolkit/analysis/TypeAnalyzer.java @@ -119,9 +119,11 @@ private FieldInfo.MappingStrategy determineMappingStrategy(TypeMirror fieldType) if (isListType(fieldType)) { TypeMirror elementType = getListElementType(fieldType); if (elementType != null) { - String elementTypeName = elementType.toString(); + String elementTypeName = getCleanTypeName(elementType); if (isStringType(elementTypeName)) { return FieldInfo.MappingStrategy.STRING_LIST; + } else if (isNumberType(elementTypeName) || isNumericPrimitive(elementType)) { + return FieldInfo.MappingStrategy.NUMBER_LIST; } else if (isNestedNumberList(elementType)) { return FieldInfo.MappingStrategy.NESTED_NUMBER_LIST; } else if (hasDynamoMappableAnnotation(elementType)) { diff --git a/src/main/java/com/github/wassertim/dynamodb/toolkit/mapping/FieldMappingCodeGenerator.java b/src/main/java/com/github/wassertim/dynamodb/toolkit/mapping/FieldMappingCodeGenerator.java index b9f259b..f595c21 100644 --- a/src/main/java/com/github/wassertim/dynamodb/toolkit/mapping/FieldMappingCodeGenerator.java +++ b/src/main/java/com/github/wassertim/dynamodb/toolkit/mapping/FieldMappingCodeGenerator.java @@ -38,6 +38,7 @@ public CodeBlock generateToAttributeValueMapping(FieldInfo field, String objectN case INSTANT -> generateInstantMapping(fieldName, getterCall); case ENUM -> generateEnumMapping(fieldName, getterCall); case STRING_LIST -> generateStringListMapping(fieldName, getterCall); + case NUMBER_LIST -> generateNumberListMapping(fieldName, getterCall); case NESTED_NUMBER_LIST -> generateNestedNumberListMapping(fieldName, getterCall); case COMPLEX_OBJECT -> generateComplexObjectMapping(field, fieldName, getterCall); case COMPLEX_LIST -> generateComplexListMapping(field, fieldName, getterCall); @@ -118,6 +119,21 @@ private CodeBlock generateStringListMapping(String fieldName, String getterCall) .build(); } + private CodeBlock generateNumberListMapping(String fieldName, String getterCall) { + ClassName attributeValue = ClassName.get(AttributeValue.class); + ClassName list = ClassName.get(List.class); + ClassName collectors = ClassName.get(Collectors.class); + + return CodeBlock.builder() + .beginControlFlow("if ($L)", utils.createNullCheck(getterCall)) + .add("$T<$T> $LList = $L.stream()\n", list, attributeValue, fieldName, getterCall) + .add(" .map(val -> $T.builder().n($T.valueOf(val)).build())\n", attributeValue, String.class) + .addStatement(" .collect($T.toList())", collectors) + .addStatement("$L", utils.createAttributePut(fieldName, utils.createListAttribute(fieldName + "List"))) + .endControlFlow() + .build(); + } + private CodeBlock generateNestedNumberListMapping(String fieldName, String getterCall) { ClassName attributeValue = ClassName.get(AttributeValue.class); ClassName list = ClassName.get(List.class); @@ -193,6 +209,7 @@ public CodeBlock generateFromAttributeValueMapping(FieldInfo field) { case INSTANT -> generateInstantDeserialization(fieldName); case ENUM -> generateEnumDeserialization(field, fieldName); case STRING_LIST -> generateStringListDeserialization(fieldName); + case NUMBER_LIST -> generateNumberListDeserialization(field, fieldName); case NESTED_NUMBER_LIST -> generateNestedNumberListDeserialization(fieldName); case COMPLEX_OBJECT -> generateComplexObjectDeserialization(field, fieldName); case COMPLEX_LIST -> generateComplexListDeserialization(field, fieldName); @@ -280,6 +297,30 @@ private CodeBlock generateStringListDeserialization(String fieldName) { .build(); } + private CodeBlock generateNumberListDeserialization(FieldInfo field, String fieldName) { + ClassName mappingUtils = ClassName.get("com.github.wassertim.dynamodb.runtime", "MappingUtils"); + ClassName attributeValue = ClassName.get(AttributeValue.class); + ClassName list = ClassName.get(List.class); + ClassName objects = ClassName.get(Objects.class); + ClassName collectors = ClassName.get(Collectors.class); + + // Determine the element type from the field + String elementTypeQualified = utils.extractListElementQualifiedType(field); + String numericMethod = utils.getNumericMethodForType(elementTypeQualified); + String javaType = utils.getJavaTypeForNumeric(elementTypeQualified); + + return CodeBlock.builder() + .addStatement("$T<$T> listValue = $T.getListSafely($LAttr)", list, attributeValue, mappingUtils, fieldName) + .beginControlFlow("if (listValue != null)") + .add("$T<$L> $LList = listValue.stream()\n", list, javaType, fieldName) + .add(" .map($T::$L)\n", mappingUtils, numericMethod) + .add(" .filter($T::nonNull)\n", objects) + .addStatement(" .collect($T.toList())", collectors) + .addStatement("builder.$L($LList)", fieldName, fieldName) + .endControlFlow() + .build(); + } + private CodeBlock generateNestedNumberListDeserialization(String fieldName) { ClassName mappingUtils = ClassName.get("com.github.wassertim.dynamodb.runtime", "MappingUtils"); ClassName attributeValue = ClassName.get(AttributeValue.class);