Skip to content

Commit 95b93d8

Browse files
committed
Fix AutoGeneratedTimestampRecordExtension failing on @DynamoDbConvertedBy list attributes (#6852)
1 parent cafa719 commit 95b93d8

File tree

4 files changed

+342
-5
lines changed

4 files changed

+342
-5
lines changed

services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
2929
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
3030
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
31+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
32+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable;
3133
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
3234
import software.amazon.awssdk.utils.CollectionUtils;
3335
import software.amazon.awssdk.utils.StringUtils;
@@ -49,11 +51,16 @@ private NestedRecordUtils() {
4951
* If the provided key contains the nested object delimiter (e.g., {@code _NESTED_ATTR_UPDATE_}), the method traverses the
5052
* nested hierarchy based on that path to locate the correct schema for the target attribute. Otherwise, it directly resolves
5153
* the list element type from the root schema using reflection.
54+
* <p>
55+
* If the list element type is not annotated with {@code @DynamoDbBean} or {@code @DynamoDbImmutable} (e.g. when a custom
56+
* converter handles serialization via {@code @DynamoDbConvertedBy}), this method returns {@code null} to indicate that no
57+
* schema introspection is possible or necessary for that element type.
5258
*
5359
* @param rootSchema The root {@link TableSchema} representing the top-level entity.
5460
* @param key The key representing the list attribute, either flat or nested (using a delimiter).
55-
* @return The {@link TableSchema} representing the list element type of the specified attribute.
56-
* @throws IllegalArgumentException If the list element class cannot be found via reflection.
61+
* @return The {@link TableSchema} representing the list element type, or {@code null} if the element type is not a
62+
* DynamoDB-annotated class.
63+
* @throws IllegalArgumentException If no converter is found for the attribute or if the converter has no type parameters.
5764
*/
5865
public static TableSchema<?> getTableSchemaForListElement(TableSchema<?> rootSchema, String key) {
5966
return getTableSchemaForListElement(rootSchema, key, new HashMap<>());
@@ -85,7 +92,12 @@ public static TableSchema<?> getTableSchemaForListElement(
8592
if (CollectionUtils.isNullOrEmpty(rawClassParameters)) {
8693
throw new IllegalArgumentException("No type parameters found for list attribute: " + key);
8794
}
88-
return TableSchema.fromClass(rawClassParameters.get(0).rawClass());
95+
Class<?> elementClass = rawClassParameters.get(0).rawClass();
96+
if (elementClass.getAnnotation(DynamoDbBean.class) == null
97+
&& elementClass.getAnnotation(DynamoDbImmutable.class) == null) {
98+
return null;
99+
}
100+
return TableSchema.fromClass(elementClass);
89101
}
90102

91103
private static TableSchema<?> listElementSchemaForDelimitedKey(
@@ -212,8 +224,9 @@ public static Optional<TableSchema<?>> getNestedSchemaCached(
212224
/**
213225
* Cached wrapper for resolving list element schema, storing results (including null) in the provided cache.
214226
* <p>
215-
* Note: {@link #getTableSchemaForListElement(TableSchema, String, Map)} does not return null today, but this helper is used
216-
* by callers that previously cached the list element schema separately, and it keeps the "cache null" behavior.
227+
* {@link #getTableSchemaForListElement(TableSchema, String, Map)} returns {@code null} when the list element type is not a
228+
* DynamoDB-annotated class (e.g. when a custom converter handles serialization). Callers should check for null before
229+
* attempting to introspect the returned schema.
217230
*/
218231
public static TableSchema<?> getListElementSchemaCached(
219232
Map<SchemaLookupKey, TableSchema<?>> cache,

services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@
7575
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidNestedAttributeName;
7676
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidNestedAttributeName.BeanWithInvalidNestedAttributeNameChild;
7777
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidRootAttributeName;
78+
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithCustomConvertedList;
79+
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithCustomConvertedMap;
80+
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.CustomConvertedPojo;
7881
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedBeanChild;
7982
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedBeanWithList;
8083
import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedImmutableChildRecordWithList;
@@ -1447,6 +1450,49 @@ public void beforeWrite_nestedChildListStartsWithPlainString_thenFollowingMapsNo
14471450
assertThat(writtenNestedList.get(1), is(childDocumentBeforeWrite));
14481451
}
14491452

1453+
@Test
1454+
public void beforeWrite_listWithCustomConverter_whenElementTypeNotAnnotated_thenTimestampSetAndListUnchanged() {
1455+
String expectedWrittenInstant = MOCKED_INSTANT_NOW.toString();
1456+
TableSchema<BeanWithCustomConvertedList> schema = BeanTableSchema.create(BeanWithCustomConvertedList.class);
1457+
BeanWithCustomConvertedList record = new BeanWithCustomConvertedList()
1458+
.setId("1")
1459+
.setCustomItems(Arrays.asList(
1460+
new CustomConvertedPojo("a", 1),
1461+
new CustomConvertedPojo("b", 2)));
1462+
1463+
Map<String, AttributeValue> putItem = new HashMap<>(schema.itemToMap(record, false));
1464+
AttributeValue originalList = putItem.get("customItems");
1465+
1466+
WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
1467+
Map<String, AttributeValue> transformed = modification.transformedItem();
1468+
1469+
assertThat(transformed, is(notNullValue()));
1470+
assertThat(transformed.get("time").s(), is(expectedWrittenInstant));
1471+
assertThat(transformed.get("customItems"), is(originalList));
1472+
}
1473+
1474+
@Test
1475+
public void beforeWrite_mapWithCustomConverter_whenValueTypeNotAnnotated_thenTimestampSetAndMapUnchanged() {
1476+
String expectedWrittenInstant = MOCKED_INSTANT_NOW.toString();
1477+
TableSchema<BeanWithCustomConvertedMap> schema = BeanTableSchema.create(BeanWithCustomConvertedMap.class);
1478+
Map<String, CustomConvertedPojo> pojoMap = new HashMap<>();
1479+
pojoMap.put("first", new CustomConvertedPojo("x", 10));
1480+
pojoMap.put("second", new CustomConvertedPojo("y", 20));
1481+
BeanWithCustomConvertedMap record = new BeanWithCustomConvertedMap()
1482+
.setId("1")
1483+
.setCustomMap(pojoMap);
1484+
1485+
Map<String, AttributeValue> putItem = new HashMap<>(schema.itemToMap(record, false));
1486+
AttributeValue originalMap = putItem.get("customMap");
1487+
1488+
WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema);
1489+
Map<String, AttributeValue> transformed = modification.transformedItem();
1490+
1491+
assertThat(transformed, is(notNullValue()));
1492+
assertThat(transformed.get("time").s(), is(expectedWrittenInstant));
1493+
assertThat(transformed.get("customMap"), is(originalMap));
1494+
}
1495+
14501496
private <T> WriteModification invokeBeforeWriteForPutItem(Map<String, AttributeValue> itemAttributes,
14511497
TableSchema<T> tableSchema) {
14521498
return invokeBeforeWriteForPutItem(itemAttributes, tableSchema.tableMetadata(), tableSchema);

services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/AutogeneratedTimestampTestModels.java

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,25 @@
1919
import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey;
2020

2121
import java.time.Instant;
22+
import java.util.ArrayList;
2223
import java.util.Collections;
24+
import java.util.HashMap;
2325
import java.util.List;
2426
import java.util.Map;
2527
import java.util.Set;
28+
import java.util.stream.Collectors;
29+
import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
30+
import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType;
2631
import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
2732
import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
2833
import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute;
2934
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticImmutableTableSchema;
3035
import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema;
3136
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
37+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy;
3238
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable;
3339
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
40+
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
3441

3542
/**
3643
* Test models specifically designed for auto-generated timestamp functionality testing. These models focus on the "time"
@@ -817,4 +824,227 @@ public BeanWithInvalidNestedAttributeNameChild setAttr_NESTED_ATTR_UPDATE_(Insta
817824
}
818825
}
819826
}
827+
828+
/**
829+
* Plain POJO with no DynamoDB annotations. Serialization is handled entirely by
830+
* {@link CustomConvertedPojoListConverter}.
831+
*/
832+
public static class CustomConvertedPojo {
833+
private String label;
834+
private int count;
835+
836+
public CustomConvertedPojo() {
837+
}
838+
839+
public CustomConvertedPojo(String label, int count) {
840+
this.label = label;
841+
this.count = count;
842+
}
843+
844+
public String getLabel() {
845+
return label;
846+
}
847+
848+
public void setLabel(String label) {
849+
this.label = label;
850+
}
851+
852+
public int getCount() {
853+
return count;
854+
}
855+
856+
public void setCount(int count) {
857+
this.count = count;
858+
}
859+
}
860+
861+
/**
862+
* Custom converter for {@code List<CustomConvertedPojo>}. The SDK should not attempt to resolve a
863+
* {@link TableSchema} for {@link CustomConvertedPojo} when this converter is present.
864+
*/
865+
public static class CustomConvertedPojoListConverter implements AttributeConverter<List<CustomConvertedPojo>> {
866+
867+
@Override
868+
public AttributeValue transformFrom(List<CustomConvertedPojo> input) {
869+
if (input == null) {
870+
return AttributeValue.builder().nul(true).build();
871+
}
872+
List<AttributeValue> items = input.stream()
873+
.map(pojo -> {
874+
Map<String, AttributeValue> map = new HashMap<>();
875+
map.put("label", AttributeValue.builder().s(pojo.getLabel()).build());
876+
map.put("count", AttributeValue.builder().n(String.valueOf(pojo.getCount())).build());
877+
return AttributeValue.builder().m(map).build();
878+
})
879+
.collect(Collectors.toList());
880+
return AttributeValue.builder().l(items).build();
881+
}
882+
883+
@Override
884+
public List<CustomConvertedPojo> transformTo(AttributeValue input) {
885+
if (input.l() == null) {
886+
return new ArrayList<>();
887+
}
888+
return input.l().stream()
889+
.map(av -> {
890+
Map<String, AttributeValue> m = av.m();
891+
CustomConvertedPojo pojo = new CustomConvertedPojo();
892+
if (m.containsKey("label")) {
893+
pojo.setLabel(m.get("label").s());
894+
}
895+
if (m.containsKey("count")) {
896+
pojo.setCount(Integer.parseInt(m.get("count").n()));
897+
}
898+
return pojo;
899+
})
900+
.collect(Collectors.toList());
901+
}
902+
903+
@Override
904+
public EnhancedType<List<CustomConvertedPojo>> type() {
905+
return EnhancedType.listOf(CustomConvertedPojo.class);
906+
}
907+
908+
@Override
909+
public AttributeValueType attributeValueType() {
910+
return AttributeValueType.L;
911+
}
912+
}
913+
914+
/**
915+
* Bean that combines {@code @DynamoDbAutoGeneratedTimestampAttribute} with a
916+
* {@code @DynamoDbConvertedBy} list whose element type ({@link CustomConvertedPojo}) has no DynamoDB
917+
* annotations. Reproduces the regression described in GitHub issue #6852.
918+
*/
919+
@DynamoDbBean
920+
public static class BeanWithCustomConvertedList {
921+
private String id;
922+
private Instant time;
923+
private List<CustomConvertedPojo> customItems;
924+
925+
@DynamoDbPartitionKey
926+
public String getId() {
927+
return id;
928+
}
929+
930+
public BeanWithCustomConvertedList setId(String id) {
931+
this.id = id;
932+
return this;
933+
}
934+
935+
@DynamoDbAutoGeneratedTimestampAttribute
936+
public Instant getTime() {
937+
return time;
938+
}
939+
940+
public BeanWithCustomConvertedList setTime(Instant time) {
941+
this.time = time;
942+
return this;
943+
}
944+
945+
@DynamoDbConvertedBy(CustomConvertedPojoListConverter.class)
946+
public List<CustomConvertedPojo> getCustomItems() {
947+
return customItems;
948+
}
949+
950+
public BeanWithCustomConvertedList setCustomItems(List<CustomConvertedPojo> customItems) {
951+
this.customItems = customItems;
952+
return this;
953+
}
954+
}
955+
956+
/**
957+
* Custom converter for {@code Map<String, CustomConvertedPojo>}. Serializes the map as a DynamoDB M attribute
958+
* whose values are themselves maps. The SDK should not attempt to resolve a {@link TableSchema} for
959+
* {@link CustomConvertedPojo} when this converter is present.
960+
*/
961+
public static class CustomConvertedPojoMapConverter implements AttributeConverter<Map<String, CustomConvertedPojo>> {
962+
963+
@Override
964+
public AttributeValue transformFrom(Map<String, CustomConvertedPojo> input) {
965+
if (input == null) {
966+
return AttributeValue.builder().nul(true).build();
967+
}
968+
Map<String, AttributeValue> map = new HashMap<>();
969+
for (Map.Entry<String, CustomConvertedPojo> entry : input.entrySet()) {
970+
Map<String, AttributeValue> inner = new HashMap<>();
971+
inner.put("label", AttributeValue.builder().s(entry.getValue().getLabel()).build());
972+
inner.put("count", AttributeValue.builder().n(String.valueOf(entry.getValue().getCount())).build());
973+
map.put(entry.getKey(), AttributeValue.builder().m(inner).build());
974+
}
975+
return AttributeValue.builder().m(map).build();
976+
}
977+
978+
@Override
979+
public Map<String, CustomConvertedPojo> transformTo(AttributeValue input) {
980+
if (input.m() == null) {
981+
return new HashMap<>();
982+
}
983+
Map<String, CustomConvertedPojo> result = new HashMap<>();
984+
for (Map.Entry<String, AttributeValue> entry : input.m().entrySet()) {
985+
Map<String, AttributeValue> m = entry.getValue().m();
986+
CustomConvertedPojo pojo = new CustomConvertedPojo();
987+
if (m.containsKey("label")) {
988+
pojo.setLabel(m.get("label").s());
989+
}
990+
if (m.containsKey("count")) {
991+
pojo.setCount(Integer.parseInt(m.get("count").n()));
992+
}
993+
result.put(entry.getKey(), pojo);
994+
}
995+
return result;
996+
}
997+
998+
@Override
999+
public EnhancedType<Map<String, CustomConvertedPojo>> type() {
1000+
return EnhancedType.mapOf(String.class, CustomConvertedPojo.class);
1001+
}
1002+
1003+
@Override
1004+
public AttributeValueType attributeValueType() {
1005+
return AttributeValueType.M;
1006+
}
1007+
}
1008+
1009+
/**
1010+
* Bean that combines {@code @DynamoDbAutoGeneratedTimestampAttribute} with a
1011+
* {@code @DynamoDbConvertedBy} map whose value type ({@link CustomConvertedPojo}) has no DynamoDB
1012+
* annotations. Verifies the map path is safe alongside the list regression test.
1013+
*/
1014+
@DynamoDbBean
1015+
public static class BeanWithCustomConvertedMap {
1016+
private String id;
1017+
private Instant time;
1018+
private Map<String, CustomConvertedPojo> customMap;
1019+
1020+
@DynamoDbPartitionKey
1021+
public String getId() {
1022+
return id;
1023+
}
1024+
1025+
public BeanWithCustomConvertedMap setId(String id) {
1026+
this.id = id;
1027+
return this;
1028+
}
1029+
1030+
@DynamoDbAutoGeneratedTimestampAttribute
1031+
public Instant getTime() {
1032+
return time;
1033+
}
1034+
1035+
public BeanWithCustomConvertedMap setTime(Instant time) {
1036+
this.time = time;
1037+
return this;
1038+
}
1039+
1040+
@DynamoDbConvertedBy(CustomConvertedPojoMapConverter.class)
1041+
public Map<String, CustomConvertedPojo> getCustomMap() {
1042+
return customMap;
1043+
}
1044+
1045+
public BeanWithCustomConvertedMap setCustomMap(Map<String, CustomConvertedPojo> customMap) {
1046+
this.customMap = customMap;
1047+
return this;
1048+
}
1049+
}
8201050
}

0 commit comments

Comments
 (0)