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"
+}
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