From 95b93d877d6515c4c919608171133019b737e810 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Sat, 11 Apr 2026 10:51:23 -0700 Subject: [PATCH 1/2] Fix AutoGeneratedTimestampRecordExtension failing on @DynamoDbConvertedBy list attributes (#6852) --- .../extensions/utility/NestedRecordUtils.java | 23 +- .../AutoGeneratedTimestampExtensionTest.java | 46 ++++ .../AutogeneratedTimestampTestModels.java | 230 ++++++++++++++++++ .../utility/NestedRecordUtilsTest.java | 48 ++++ 4 files changed, 342 insertions(+), 5 deletions(-) diff --git a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java index 90bd1f46e5b1..f36e34cc9cdb 100644 --- a/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java +++ b/services-custom/dynamodb-enhanced/src/main/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtils.java @@ -28,6 +28,8 @@ import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.utils.CollectionUtils; import software.amazon.awssdk.utils.StringUtils; @@ -49,11 +51,16 @@ private NestedRecordUtils() { * If the provided key contains the nested object delimiter (e.g., {@code _NESTED_ATTR_UPDATE_}), the method traverses the * nested hierarchy based on that path to locate the correct schema for the target attribute. Otherwise, it directly resolves * the list element type from the root schema using reflection. + *

+ * If the list element type is not annotated with {@code @DynamoDbBean} or {@code @DynamoDbImmutable} (e.g. when a custom + * converter handles serialization via {@code @DynamoDbConvertedBy}), this method returns {@code null} to indicate that no + * schema introspection is possible or necessary for that element type. * * @param rootSchema The root {@link TableSchema} representing the top-level entity. * @param key The key representing the list attribute, either flat or nested (using a delimiter). - * @return The {@link TableSchema} representing the list element type of the specified attribute. - * @throws IllegalArgumentException If the list element class cannot be found via reflection. + * @return The {@link TableSchema} representing the list element type, or {@code null} if the element type is not a + * DynamoDB-annotated class. + * @throws IllegalArgumentException If no converter is found for the attribute or if the converter has no type parameters. */ public static TableSchema getTableSchemaForListElement(TableSchema rootSchema, String key) { return getTableSchemaForListElement(rootSchema, key, new HashMap<>()); @@ -85,7 +92,12 @@ public static TableSchema getTableSchemaForListElement( if (CollectionUtils.isNullOrEmpty(rawClassParameters)) { throw new IllegalArgumentException("No type parameters found for list attribute: " + key); } - return TableSchema.fromClass(rawClassParameters.get(0).rawClass()); + Class elementClass = rawClassParameters.get(0).rawClass(); + if (elementClass.getAnnotation(DynamoDbBean.class) == null + && elementClass.getAnnotation(DynamoDbImmutable.class) == null) { + return null; + } + return TableSchema.fromClass(elementClass); } private static TableSchema listElementSchemaForDelimitedKey( @@ -212,8 +224,9 @@ public static Optional> getNestedSchemaCached( /** * Cached wrapper for resolving list element schema, storing results (including null) in the provided cache. *

- * Note: {@link #getTableSchemaForListElement(TableSchema, String, Map)} does not return null today, but this helper is used - * by callers that previously cached the list element schema separately, and it keeps the "cache null" behavior. + * {@link #getTableSchemaForListElement(TableSchema, String, Map)} returns {@code null} when the list element type is not a + * DynamoDB-annotated class (e.g. when a custom converter handles serialization). Callers should check for null before + * attempting to introspect the returned schema. */ public static TableSchema getListElementSchemaCached( Map> cache, diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java index 918834235398..5fbdf77963c4 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/AutoGeneratedTimestampExtensionTest.java @@ -75,6 +75,9 @@ import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidNestedAttributeName; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidNestedAttributeName.BeanWithInvalidNestedAttributeNameChild; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithInvalidRootAttributeName; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithCustomConvertedList; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.BeanWithCustomConvertedMap; +import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.CustomConvertedPojo; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedBeanChild; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedBeanWithList; import software.amazon.awssdk.enhanced.dynamodb.functionaltests.models.AutogeneratedTimestampTestModels.NestedImmutableChildRecordWithList; @@ -1447,6 +1450,49 @@ public void beforeWrite_nestedChildListStartsWithPlainString_thenFollowingMapsNo assertThat(writtenNestedList.get(1), is(childDocumentBeforeWrite)); } + @Test + public void beforeWrite_listWithCustomConverter_whenElementTypeNotAnnotated_thenTimestampSetAndListUnchanged() { + String expectedWrittenInstant = MOCKED_INSTANT_NOW.toString(); + TableSchema schema = BeanTableSchema.create(BeanWithCustomConvertedList.class); + BeanWithCustomConvertedList record = new BeanWithCustomConvertedList() + .setId("1") + .setCustomItems(Arrays.asList( + new CustomConvertedPojo("a", 1), + new CustomConvertedPojo("b", 2))); + + Map putItem = new HashMap<>(schema.itemToMap(record, false)); + AttributeValue originalList = putItem.get("customItems"); + + WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema); + Map transformed = modification.transformedItem(); + + assertThat(transformed, is(notNullValue())); + assertThat(transformed.get("time").s(), is(expectedWrittenInstant)); + assertThat(transformed.get("customItems"), is(originalList)); + } + + @Test + public void beforeWrite_mapWithCustomConverter_whenValueTypeNotAnnotated_thenTimestampSetAndMapUnchanged() { + String expectedWrittenInstant = MOCKED_INSTANT_NOW.toString(); + TableSchema schema = BeanTableSchema.create(BeanWithCustomConvertedMap.class); + Map pojoMap = new HashMap<>(); + pojoMap.put("first", new CustomConvertedPojo("x", 10)); + pojoMap.put("second", new CustomConvertedPojo("y", 20)); + BeanWithCustomConvertedMap record = new BeanWithCustomConvertedMap() + .setId("1") + .setCustomMap(pojoMap); + + Map putItem = new HashMap<>(schema.itemToMap(record, false)); + AttributeValue originalMap = putItem.get("customMap"); + + WriteModification modification = invokeBeforeWriteForPutItem(putItem, schema); + Map transformed = modification.transformedItem(); + + assertThat(transformed, is(notNullValue())); + assertThat(transformed.get("time").s(), is(expectedWrittenInstant)); + assertThat(transformed.get("customMap"), is(originalMap)); + } + private WriteModification invokeBeforeWriteForPutItem(Map itemAttributes, TableSchema tableSchema) { return invokeBeforeWriteForPutItem(itemAttributes, tableSchema.tableMetadata(), tableSchema); diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/AutogeneratedTimestampTestModels.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/AutogeneratedTimestampTestModels.java index 527fc44c6695..ee2807d91614 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/AutogeneratedTimestampTestModels.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/functionaltests/models/AutogeneratedTimestampTestModels.java @@ -19,18 +19,25 @@ import static software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags.primaryPartitionKey; import java.time.Instant; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; +import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute; 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.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; /** * 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 } } } + + /** + * Plain POJO with no DynamoDB annotations. Serialization is handled entirely by + * {@link CustomConvertedPojoListConverter}. + */ + public static class CustomConvertedPojo { + private String label; + private int count; + + public CustomConvertedPojo() { + } + + public CustomConvertedPojo(String label, int count) { + this.label = label; + this.count = count; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + } + + /** + * Custom converter for {@code List}. The SDK should not attempt to resolve a + * {@link TableSchema} for {@link CustomConvertedPojo} when this converter is present. + */ + public static class CustomConvertedPojoListConverter implements AttributeConverter> { + + @Override + public AttributeValue transformFrom(List input) { + if (input == null) { + return AttributeValue.builder().nul(true).build(); + } + List items = input.stream() + .map(pojo -> { + Map map = new HashMap<>(); + map.put("label", AttributeValue.builder().s(pojo.getLabel()).build()); + map.put("count", AttributeValue.builder().n(String.valueOf(pojo.getCount())).build()); + return AttributeValue.builder().m(map).build(); + }) + .collect(Collectors.toList()); + return AttributeValue.builder().l(items).build(); + } + + @Override + public List transformTo(AttributeValue input) { + if (input.l() == null) { + return new ArrayList<>(); + } + return input.l().stream() + .map(av -> { + Map m = av.m(); + CustomConvertedPojo pojo = new CustomConvertedPojo(); + if (m.containsKey("label")) { + pojo.setLabel(m.get("label").s()); + } + if (m.containsKey("count")) { + pojo.setCount(Integer.parseInt(m.get("count").n())); + } + return pojo; + }) + .collect(Collectors.toList()); + } + + @Override + public EnhancedType> type() { + return EnhancedType.listOf(CustomConvertedPojo.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.L; + } + } + + /** + * Bean that combines {@code @DynamoDbAutoGeneratedTimestampAttribute} with a + * {@code @DynamoDbConvertedBy} list whose element type ({@link CustomConvertedPojo}) has no DynamoDB + * annotations. Reproduces the regression described in GitHub issue #6852. + */ + @DynamoDbBean + public static class BeanWithCustomConvertedList { + private String id; + private Instant time; + private List customItems; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithCustomConvertedList setId(String id) { + this.id = id; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public BeanWithCustomConvertedList setTime(Instant time) { + this.time = time; + return this; + } + + @DynamoDbConvertedBy(CustomConvertedPojoListConverter.class) + public List getCustomItems() { + return customItems; + } + + public BeanWithCustomConvertedList setCustomItems(List customItems) { + this.customItems = customItems; + return this; + } + } + + /** + * Custom converter for {@code Map}. Serializes the map as a DynamoDB M attribute + * whose values are themselves maps. The SDK should not attempt to resolve a {@link TableSchema} for + * {@link CustomConvertedPojo} when this converter is present. + */ + public static class CustomConvertedPojoMapConverter implements AttributeConverter> { + + @Override + public AttributeValue transformFrom(Map input) { + if (input == null) { + return AttributeValue.builder().nul(true).build(); + } + Map map = new HashMap<>(); + for (Map.Entry entry : input.entrySet()) { + Map inner = new HashMap<>(); + inner.put("label", AttributeValue.builder().s(entry.getValue().getLabel()).build()); + inner.put("count", AttributeValue.builder().n(String.valueOf(entry.getValue().getCount())).build()); + map.put(entry.getKey(), AttributeValue.builder().m(inner).build()); + } + return AttributeValue.builder().m(map).build(); + } + + @Override + public Map transformTo(AttributeValue input) { + if (input.m() == null) { + return new HashMap<>(); + } + Map result = new HashMap<>(); + for (Map.Entry entry : input.m().entrySet()) { + Map m = entry.getValue().m(); + CustomConvertedPojo pojo = new CustomConvertedPojo(); + if (m.containsKey("label")) { + pojo.setLabel(m.get("label").s()); + } + if (m.containsKey("count")) { + pojo.setCount(Integer.parseInt(m.get("count").n())); + } + result.put(entry.getKey(), pojo); + } + return result; + } + + @Override + public EnhancedType> type() { + return EnhancedType.mapOf(String.class, CustomConvertedPojo.class); + } + + @Override + public AttributeValueType attributeValueType() { + return AttributeValueType.M; + } + } + + /** + * Bean that combines {@code @DynamoDbAutoGeneratedTimestampAttribute} with a + * {@code @DynamoDbConvertedBy} map whose value type ({@link CustomConvertedPojo}) has no DynamoDB + * annotations. Verifies the map path is safe alongside the list regression test. + */ + @DynamoDbBean + public static class BeanWithCustomConvertedMap { + private String id; + private Instant time; + private Map customMap; + + @DynamoDbPartitionKey + public String getId() { + return id; + } + + public BeanWithCustomConvertedMap setId(String id) { + this.id = id; + return this; + } + + @DynamoDbAutoGeneratedTimestampAttribute + public Instant getTime() { + return time; + } + + public BeanWithCustomConvertedMap setTime(Instant time) { + this.time = time; + return this; + } + + @DynamoDbConvertedBy(CustomConvertedPojoMapConverter.class) + public Map getCustomMap() { + return customMap; + } + + public BeanWithCustomConvertedMap setCustomMap(Map customMap) { + this.customMap = customMap; + return this; + } + } } \ No newline at end of file diff --git a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java index 3f595a89ddc6..3846952cddb7 100644 --- a/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java +++ b/services-custom/dynamodb-enhanced/src/test/java/software/amazon/awssdk/enhanced/dynamodb/internal/extensions/utility/NestedRecordUtilsTest.java @@ -149,6 +149,37 @@ public void getTableSchemaForListElement_withNullRawClassParameters_throwsIllega .hasMessageContaining("No type parameters found for list attribute: nullParamsAttribute"); } + @Test + public void getTableSchemaForListElement_whenElementTypeNotDynamoDbAnnotated_returnsNull() { + List> rawClassParameters = Collections.singletonList(EnhancedType.of(NonAnnotatedPojo.class)); + when(mockSchema.converterForAttribute("customConverted")).thenReturn(mockAttributeConverter); + when(mockAttributeConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(rawClassParameters); + + TableSchema result = NestedRecordUtils.getTableSchemaForListElement(mockSchema, "customConverted"); + + assertThat(result).isNull(); + } + + @Test + public void getListElementSchemaCached_whenElementTypeNotDynamoDbAnnotated_returnsNullAndCachesResult() { + List> rawClassParameters = Collections.singletonList(EnhancedType.of(NonAnnotatedPojo.class)); + when(mockSchema.converterForAttribute("customConverted")).thenReturn(mockAttributeConverter); + when(mockAttributeConverter.type()).thenReturn(mockEnhancedType); + when(mockEnhancedType.rawClassParameters()).thenReturn(rawClassParameters); + + Map> cache = new HashMap<>(); + TableSchema result = NestedRecordUtils.getListElementSchemaCached( + cache, mockSchema, "customConverted", new HashMap<>()); + + assertThat(result).isNull(); + + NestedRecordUtils.SchemaLookupKey expectedKey = + new NestedRecordUtils.SchemaLookupKey(mockSchema, "customConverted"); + assertThat(cache).containsKey(expectedKey); + assertThat(cache.get(expectedKey)).isNull(); + } + @Test public void getTableSchemaForListElement_withDeepNestedPath_returnsCorrectParts() { String nestedKey = "nestedItem" + NESTED_OBJECT_UPDATE + "tags"; @@ -471,4 +502,21 @@ public int hashCode() { return Objects.hashCode(attribute); } } + + /** + * Plain POJO with no DynamoDB annotations. Used to verify that + * {@code getTableSchemaForListElement} returns null instead of throwing + * when the list element type is not a DynamoDB-annotated class. + */ + public static class NonAnnotatedPojo { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } } \ No newline at end of file From 33809d7bba73af79f776832ac399ce46d8060fe9 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Sat, 11 Apr 2026 13:26:00 -0700 Subject: [PATCH 2/2] Add changelog --- .../bugfix-AmazonDynamoDBEnhancedClient-e8e2684.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-e8e2684.json diff --git a/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-e8e2684.json b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-e8e2684.json new file mode 100644 index 000000000000..e010950a2dac --- /dev/null +++ b/.changes/next-release/bugfix-AmazonDynamoDBEnhancedClient-e8e2684.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "Amazon DynamoDB Enhanced Client", + "contributor": "", + "description": "Fix AutoGeneratedTimestampRecordExtension failing on @DynamoDbConvertedBy list attributes" +}