Added support for @DynamoDbUpdateBehavior on attributes within nested objects#6708
Conversation
4c585a0 to
6f6f15b
Compare
6f6f15b to
3e5c890
Compare
| TableSchema<?> elementListSchema = getTableSchemaForListElement(context.tableSchema(), key); | ||
| if (elementListSchema != null) { | ||
| TableSchema<?> cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema); | ||
| Collection<AttributeValue> updatedList = new ArrayList<>(value.l().size()); |
There was a problem hiding this comment.
you can avoid creating new arraylist and create it only when you use it in the loop
| TableSchema<?> listElementSchema = getTableSchemaForListElement(nestedSchema, nestedKey); | ||
| if (listElementSchema != null) { | ||
| TableSchema<?> cachedSchema = getOrCacheSchema(schemaInstanceCache, listElementSchema); | ||
| Collection<AttributeValue> updatedList = new ArrayList<>(nestedValue.l().size()); |
There was a problem hiding this comment.
This is more of a question than ask to change immediately.
So, we are doing eager arraylist creation which will create a new array list (with 8kb I guess) in memory. You can lazy init the array list to avoid that. It will for sure make the implementation a bit more complex but creating so many objects in memory also wouldn't be ideal.
What's your thoughts on this, leave it simple in favor of code readability but sacrifice memory or not ?
} else if (nestedValue.hasL() && !nestedValue.l().isEmpty()) {
AttributeValue firstElement = nestedValue.l().stream()
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
if (firstElement != null && firstElement.hasM()) {
TableSchema<?> listElementSchema = getTableSchemaForListElement(nestedSchema, nestedKey);
if (listElementSchema != null) {
TableSchema<?> cachedSchema = getOrCacheSchema(schemaInstanceCache, listElementSchema);
// LAZY: Start with null
Collection<AttributeValue> updatedList = null;
boolean listModified = false;
List<AttributeValue> originalList = nestedValue.l();
for (int i = 0; i < originalList.size(); i++) {
AttributeValue listItem = originalList.get(i);
if (listItem != null && listItem.hasM()) {
Map<String, AttributeValue> processedMap = processNestedObject(
listItem.m(),
cachedSchema,
currentInstant,
schemaInstanceCache
);
AttributeValue processedItem = AttributeValue.builder().m(processedMap).build();
// Check if this item was modified
if (!Objects.equals(processedItem, listItem)) {
// First modification detected!
if (!listModified) {
// Create list NOW and copy all previous items
updatedList = new ArrayList<>(originalList.size());
for (int j = 0; j < i; j++) {
updatedList.add(originalList.get(j));
}
listModified = true;
}
// Add the modified item
updatedList.add(processedItem);
} else if (listModified) {
// Already modifying, add unchanged item
updatedList.add(listItem);
}
// else: No changes yet, don't create list
} else {
// Handle null or non-map items
if (listModified) {
updatedList.add(listItem);
}
}
}
// Only update if something actually changed
if (listModified) {
if (!updated) {
updatedNestedMap = new HashMap<>(nestedMap);
updated = true;
}
updatedNestedMap.put(nestedKey, AttributeValue.builder().l(updatedList).build());
}
}
}
}| staticSchema.isPresent() | ||
| ? staticSchema.get() | ||
| : TableSchema.fromClass(Class.forName( | ||
| rootSchema.converterForAttribute(key).type().rawClassParameters().get(0).rawClass().getName())); |
There was a problem hiding this comment.
Is there any chance converterForAttribute(key) might return null or rawClassParameters() might return an empty list, causing get(0) to throw IndexOutOfBoundsException ?
There was a problem hiding this comment.
Hello @amzn-erdemkemer,
Thank you for the observation. The following changes were made to address both issues:
1. Null converterForAttribute(key)
A check was added to fail when no converter is found:
if (converter == null) {
throw new IllegalArgumentException("No converter found for attribute: " + key);
}Test (from NestedRecordUtilsTest.java):
@Test
public void getTableSchemaForListElement_withNullConverter_throwsIllegalArgumentException() {
when(objectSchema.converterForAttribute("nonExistentAttribute")).thenReturn(null);
assertThatThrownBy(() ->
NestedRecordUtils.getTableSchemaForListElement(objectSchema, "nonExistentAttribute"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("No converter found for attribute: nonExistentAttribute");
}2. Empty or null rawClassParameters()
A validation was added to ensure type parameters are present before accessing index 0:
if (CollectionUtils.isNullOrEmpty(rawClassParameters)) {
throw new IllegalArgumentException("No type parameters found for list attribute: " + key);
}Tests (from NestedRecordUtilsTest.java):
@Test
public void getTableSchemaForListElement_withEmptyRawClassParameters_throwsIllegalArgumentException() {
when(objectSchema.converterForAttribute("emptyParamsAttribute")).thenReturn(objectConverter);
when(objectConverter.type()).thenReturn(objectType);
assertThatThrownBy(() ->
NestedRecordUtils.getTableSchemaForListElement(objectSchema, "emptyParamsAttribute"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("No type parameters found for list attribute: emptyParamsAttribute");
}
@Test
public void getTableSchemaForListElement_withNullRawClassParameters_throwsIllegalArgumentException() {
when(objectSchema.converterForAttribute("nullParamsAttribute")).thenReturn(objectConverter);
when(objectConverter.type()).thenReturn(objectType);
assertThatThrownBy(() ->
NestedRecordUtils.getTableSchemaForListElement(objectSchema, "nullParamsAttribute"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("No type parameters found for list attribute: nullParamsAttribute");
}|
|
||
| for (int i = 0; i < parts.length - 1; i++) { | ||
| Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(currentSchema, parts[i]); | ||
| if (nestedSchema.isPresent()) { |
There was a problem hiding this comment.
When nestedSchema.isPresent() is false, currentSchema is not updated, but the loop continues. Doesn't it mean that the subsequent iterations might be working with the wrong schema level ?
| return schemaMap; | ||
| } | ||
|
|
||
| public static String reconstructCompositeKey(String path, String attributeName) { |
There was a problem hiding this comment.
I have added javadoc for reconstructCompositeKey method.
| .build(); | ||
| } | ||
|
|
||
| private static TableSchema<?> getOrCacheSchema( |
There was a problem hiding this comment.
Im not sure I understand how this caching helps. We are adding same object as key and value.
// First call with a schema instance
TableSchema<?> schema1 = getNestedSchema(context.tableSchema(), "address");
// schema1 = TableSchema@12345
// Call getOrCacheSchema
schemaInstanceCache.get(schema1); // Returns null (first time)
schemaInstanceCache.put(schema1, schema1); // Stores: {TableSchema@12345 -> TableSchema@12345}
return schema1; // Returns TableSchema@12345
// Second call with THE SAME schema instance
TableSchema<?> schema2 = getNestedSchema(context.tableSchema(), "address");
// schema2 = TableSchema@12345 (SAME INSTANCE!)
// Call getOrCacheSchema again
schemaInstanceCache.get(schema2); // Returns TableSchema@12345 (cache hit!)
return schema2; // Returns TableSchema@12345getNestedSchema() already returns the same instance for the same attribute path. The cache is just checking if we've seen this exact object reference before, but we're passing in the same reference we just got, am i missing something ?
| List<String> pathFieldNames = Arrays.asList(PATTERN.split(key)); | ||
| String attributeName = pathFieldNames.get(pathFieldNames.size() - 1); | ||
|
|
||
| for (int i = 0; i < pathFieldNames.size() - 1; i++) { |
There was a problem hiding this comment.
If getNestedSchema() returns Optional.empty() at any level, the code silently continues with the wrong schema, then currentSchema stays at the previous level UpdateBehaviorTag.resolveForAttribute() is called with potentially wrong schema ? or is this not possible ?
| * @return an {@link Optional} containing the nested attribute's {@link TableSchema}, or empty if unavailable | ||
| */ | ||
| public static Optional<? extends TableSchema<?>> getNestedSchema(TableSchema<?> parentSchema, String attributeName) { | ||
| EnhancedType<?> enhancedType = parentSchema.converterForAttribute(attributeName).type(); |
There was a problem hiding this comment.
do we need any null check on converterForAttribute() result ?
There was a problem hiding this comment.
Hello,
I have added a null check for the result of converterForAttribute() and also introduced few additional validations:
Updated implementation:
public static Optional<? extends TableSchema<?>> getNestedSchema(TableSchema<?> parentSchema, CharSequence attributeName) {
if (parentSchema == null) {
throw new IllegalArgumentException("Parent schema cannot be null.");
}
if (StringUtils.isEmpty(attributeName)) {
throw new IllegalArgumentException("Attribute name cannot be null or empty.");
}
AttributeConverter<?> converter = parentSchema.converterForAttribute(attributeName);
if (converter == null) {
return Optional.empty();
}
EnhancedType<?> enhancedType = converter.type();
if (enhancedType == null) {
return Optional.empty();
}
List<EnhancedType<?>> rawClassParameters = enhancedType.rawClassParameters();
if (!CollectionUtils.isNullOrEmpty(rawClassParameters)) {
enhancedType = rawClassParameters.get(0);
}
return enhancedType.tableSchema();
}Tests (from EnhancedClientUtilsTest):
- parentSchema is null → throws IllegalArgumentException
@Test
public void getNestedSchema_withNullParentSchema_throwsIllegalArgumentException() {
assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(null, "attributeName"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Parent schema cannot be null");
}- attributeName is null/empty → throws IllegalArgumentException
@Test
public void getNestedSchema_withNullAttributeName_throwsIllegalArgumentException() {
assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(mockSchema, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Attribute name cannot be null or empty");
}
@Test
public void getNestedSchema_withEmptyAttributeName_throwsIllegalArgumentException() {
assertThatThrownBy(() -> EnhancedClientUtils.getNestedSchema(mockSchema, ""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Attribute name cannot be null or empty");
}- converterForAttribute() returns null → returns Optional.empty()
@Test
public void getNestedSchema_withNullConverter_returnsEmpty() {
when(mockSchema.converterForAttribute("nonExistentAttribute")).thenReturn(null);
Optional<? extends TableSchema<?>> result =
EnhancedClientUtils.getNestedSchema(mockSchema, "nonExistentAttribute");
assertThat(result).isEmpty();
}- converter.type() returns null → returns Optional.empty()
@Test
public void getNestedSchema_withNullEnhancedType_returnsEmpty() {
when(mockSchema.converterForAttribute("attributeWithNullType")).thenReturn(mockConverter);
when(mockConverter.type()).thenReturn(null);
Optional<? extends TableSchema<?>> result =
EnhancedClientUtils.getNestedSchema(mockSchema, "attributeWithNullType");
assertThat(result).isEmpty();
}- rawClassParameters() is null → uses original type
@Test
public void getNestedSchema_withNullParameters_usesOriginalType() {
when(mockSchema.converterForAttribute("simpleAttribute")).thenReturn(mockConverter);
when(mockConverter.type()).thenReturn(mockEnhancedType);
when(mockEnhancedType.rawClassParameters()).thenReturn(null);
when(mockEnhancedType.tableSchema()).thenReturn(Optional.of(mockNestedSchema));
Optional<? extends TableSchema<?>> result =
EnhancedClientUtils.getNestedSchema(mockSchema, "simpleAttribute");
assertThat(result).isPresent();
assertThat(result.get()).isEqualTo(mockNestedSchema);
}| } | ||
|
|
||
| @DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS) | ||
| public Instant getAttr_NESTED_ATTR_UPDATE_() { |
There was a problem hiding this comment.
Fieldname is not matching with getter name, Field is named childAttr_NESTED_ATTR_UPDATE_
Getter is named getAttr_NESTED_ATTR_UPDATE_() (missing "child" prefix), Setter parameter is named attr_NESTED_ATTR_UPDATE_ (different from field name)
Either: Rename field to attr_NESTED_ATTR_UPDATE_ to match getter/setter or rename getter/setter to getChildAttr_NESTED_ATTR_UPDATE_() to match field
There was a problem hiding this comment.
Hello,
Thank you for the feedback. The getter and setter methods have been updated to address this:
private Instant childAttr_NESTED_ATTR_UPDATE_;
@DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
public Instant getChildAttr_NESTED_ATTR_UPDATE_() {
return childAttr_NESTED_ATTR_UPDATE_;
}
public ChildBeanWithInvalidAttributeName setChildAttr_NESTED_ATTR_UPDATE_(Instant childAttr_NESTED_ATTR_UPDATE_) {
this.childAttr_NESTED_ATTR_UPDATE_ = childAttr_NESTED_ATTR_UPDATE_;
return this;
}| assertThat(result.getId()).isEqualTo("initial_id"); | ||
|
|
||
| // update id (no annotation, defaults to WRITE_ALWAYS) - should change | ||
| result.setId("updated_id"); |
There was a problem hiding this comment.
Are we updating partition key ? if we do, next line should fail. I think this is the case for many other tests below.
Is it possible to update partition key ?
There was a problem hiding this comment.
Hello @amzn-erdemkemer,
You are correct. For this flow, the expected behavior is that the partition key is not modified, while only the fields annotated with @DynamoDbUpdateBehavior are updated.
To better validate this behavior, I have refactored the tests in NestedUpdateBehaviorTest.java to provide more detailed coverage of how update behavior is applied. The test models used are defined in UpdateBehaviorTestModels.java and each includes two attributes that are verified after each update operation:
1. WRITE_ALWAYS field
This field is always written (overwritten) during an update.
private String writeAlwaysField;
@DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
public String getWriteAlwaysField() {
return writeAlwaysField;
}2. WRITE_IF_NOT_EXISTS field
This field is written only if it does not already exist in the database.
private String writeOnceField;
@DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
public String getWriteOnceField() {
return writeOnceField;
}Example bean:
@DynamoDbBean
public static class SimpleBean {
private String id;
private String writeAlwaysField;
private String writeOnceField;
private List<SimpleBeanChild> childList;
...
@DynamoDbUpdateBehavior(UpdateBehavior.WRITE_ALWAYS)
public String getWriteAlwaysField() {
return writeAlwaysField;
}
@DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
public String getWriteOnceField() {
return writeOnceField;
}
}Other covered models:
The same two fields (writeAlwaysField / writeOnceField) are present in the following models to ensure consistent behavior across schema types:
- SimpleBean
- NestedBean
- SimpleStaticRecord
- NestedStaticRecord
Test coverage:
In NestedUpdateBehaviorTest.java, tests cover all combinations of:
- Table schema types: bean, immutable, static, static immutable
- Record structures: simple and nested
For each combination, the tests verify - after a put operation - that:
- writeAlwaysField is always overwritten
- writeOnceField is written only when absent
Tests included:
beanSchema_simpleRecord_updateBehavior_isRespected
beanSchema_nestedRecord_updateBehavior_isRespected
immutableSchema_simpleRecord_updateBehavior_isRespected
immutableSchema_nestedRecord_updateBehavior_isRespected
staticSchema_simpleRecord_updateBehavior_isRespected
staticSchema_nestedRecord_updateBehavior_isRespected
staticImmutableSchema_simpleRecord_updateBehavior_isRespected
staticImmutableSchema_nestedRecord_updateBehavior_isRespected
| .setChildList(Arrays.asList( | ||
| new UpdateBehaviorTestModels.SimpleBeanChild().setId("child1").setAttr("attr_child1"), | ||
| new UpdateBehaviorTestModels.SimpleBeanChild().setId("child2").setAttr("attr_child2"))); | ||
| initial.setId("initial_id"); |
There was a problem hiding this comment.
we first set the id to 1 , then initial_id, is there a particular reason or just a typo ?
There was a problem hiding this comment.
Hello @amzn-erdemkemer,
Thank you for the observation! This has been corrected and the entire test class has been updated as described in the previous comment.
…nnotations-in-nested-objects
…nnotations-in-nested-objects
Description
Added support for @DynamoDbUpdateBehavior on attributes within nested objects. The @DynamoDbUpdateBehavior annotation will only take effect for nested attributes when using IgnoreNullsMode.SCALAR_ONLY.
Motivation and Context
@DynamoDbUpdateBehavior to work on nested objects too.
Modifications
Support for
@DynamoDbUpdateBehavioron nested attributes was implemented by accounting for the two possible representations of nested objects during update operations:IgnoreNullsMode.IgnoreNullsMode.SCALAR_ONLYis used, nested attributes are flattened using the internal_NESTED_ATTR_UPDATE_marker. This behavior is handled inUpdateItemOperation.transformItemToMapForUpdateExpression.Both scenarios are now supported, and the same generated timestamp is applied consistently across both top-level and nested attributes.
In parallel, the evaluation of the
IgnoreNullsModeparameter within update requests was reviewed and refined. As part of this change:UpdateExpressionUtilsnow evaluates the@DynamoDbUpdateBehaviorannotation only whenIgnoreNullsMode.SCALAR_ONLYis configured._NESTED_ATTR_UPDATE_marker.@DynamoDbUpdateBehaviorto lists of nested objects is explicitly not supported, as DynamoDB update operations replace the entire list and do not allow updates to individual list elements.Testing
Existing test coverage was updated where necessary, and additional tests were introduced to validate the new behavior across nested object scenarios and different
IgnoreNullsModeconfigurations.Test Coverage Checklist
Types of changes
Checklist
mvn installsucceedsscripts/new-changescript and following the instructions. Commit the new file created by the script in.changes/next-releasewith your changes.License