Skip to content

Conversation

@anasatirbasa
Copy link
Contributor

@anasatirbasa anasatirbasa commented Feb 4, 2026

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 @DynamoDbUpdateBehavior on nested attributes was implemented by accounting for the two possible representations of nested objects during update operations:

  • Complete map updates: Nested objects may be treated as whole maps, depending on the operation type and the configured IgnoreNullsMode.
  • Flattened attribute updates: When IgnoreNullsMode.SCALAR_ONLY is used, nested attributes are flattened using the internal _NESTED_ATTR_UPDATE_ marker. This behavior is handled in UpdateItemOperation.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 IgnoreNullsMode parameter within update requests was reviewed and refined. As part of this change:

  • UpdateExpressionUtils now evaluates the @DynamoDbUpdateBehavior annotation only when IgnoreNullsMode.SCALAR_ONLY is configured.
  • The annotation is applied exclusively to flattened nested attributes identified by the _NESTED_ATTR_UPDATE_ marker.
  • Applying @DynamoDbUpdateBehavior to 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 IgnoreNullsMode configurations.

Test Coverage Checklist

Scenario Done Comments if Not Done
1. Different TableSchema Creation Methods
a. TableSchema.fromBean(Customer.class) [x]
b. TableSchema.fromImmutableClass(Customer.class) for immutable classes [x]
c. TableSchema.documentSchemaBuilder().build() [x]
d. StaticTableSchema.builder(Customer.class) [x]
**2. Nesting of Different TableSchema Types ** (Ref-1)
a. @DynamoDbBean with non-null nested @DynamoDbBean attribute [x]
b. @DynamoDbBean with non-null nested @DynamoDbImmutable attribute [ ]
c. @DynamoDbImmutable with non-null nested @DynamoDbBean attribute [ ]
d. @DynamoDbBean with null nested @DynamoDbBean attribute [x]
e. @DynamoDbBean with null nested @DynamoDbImmutable attribute [ ]
f. @DynamoDbImmutable with null nested @DynamoDbBean attribute [ ]
3. CRUD Operations (Ref-2) Existing tests passed
a. scan() [ ]
b. query() [ ]
c. updateItem() [x]
d. putItem() [x]
e. getItem() [x]
f. deleteItem() [ ]
g. batchGetItem() [ ]
h. batchWriteItem() [ ]
i. transactGetItems() [ ]
j. transactWriteItems() [ ]
4. Null Handling for Different Attribute types (Ref-3)
a. top-level null attributes [x]
b. collections with null elements [ ]
c. maps with null values [ ]
d. full serialization/deserialization cycle with null values [ ]
5. AsyncTable and SyncTable Existing tests passed
a. DynamoDbAsyncTable Testing [ ]
b. DynamoDbTable Testing [x]
6. New/Modification in Extensions
a. Tables with Scenario in ScenarioSl No.1 (All table schemas are Must) [x]
b. Test with Default Values in Annotations [x]
c. Combination of Annotation and Builder passes extension [x]
7. New/Modification in Converters Not impacted, existing tests passed
a. Tables with Scenario in ScenarioSl No.1 (All table schemas are Must) [ ]
b. Test with Default Values in Annotations [ ]
c. Test All Scenarios from 1 to 5 [ ]

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the CONTRIBUTING document
  • Local run of mvn install succeeds
  • My code follows the code style of this project
  • My change requires a change to the Javadoc documentation
  • I have updated the Javadoc documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed
  • I have added a changelog entry. Adding a new entry must be accomplished by running the scripts/new-change script and following the instructions. Commit the new file created by the script in .changes/next-release with your changes.
  • My change is to implement 1.11 parity feature and I have updated LaunchChangelog

License

  • I confirm that this pull request can be released under the Apache 2 license

@anasatirbasa anasatirbasa force-pushed the feature/added-support-for-updateBehavior-annotations-in-nested-objects branch from 4c585a0 to 6f6f15b Compare February 4, 2026 05:27
@anasatirbasa anasatirbasa force-pushed the feature/added-support-for-updateBehavior-annotations-in-nested-objects branch from 6f6f15b to 3e5c890 Compare February 4, 2026 05:46
@anasatirbasa anasatirbasa marked this pull request as ready for review February 4, 2026 07:55
@anasatirbasa anasatirbasa requested a review from a team as a code owner February 4, 2026 07:55
TableSchema<?> elementListSchema = getTableSchemaForListElement(context.tableSchema(), key);
if (elementListSchema != null) {
TableSchema<?> cachedSchema = getOrCacheSchema(schemaInstanceCache, elementListSchema);
Collection<AttributeValue> updatedList = new ArrayList<>(value.l().size());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());
Copy link

@amzn-erdemkemer amzn-erdemkemer Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any chance converterForAttribute(key) might return null or rawClassParameters() might return an empty list, causing get(0) to throw IndexOutOfBoundsException ?


for (int i = 0; i < parts.length - 1; i++) {
Optional<? extends TableSchema<?>> nestedSchema = getNestedSchema(currentSchema, parts[i]);
if (nestedSchema.isPresent()) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing javadoc

.build();
}

private static TableSchema<?> getOrCacheSchema(
Copy link

@amzn-erdemkemer amzn-erdemkemer Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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@12345

getNestedSchema() 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++) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need any null check on converterForAttribute() result ?

}

@DynamoDbUpdateBehavior(UpdateBehavior.WRITE_IF_NOT_EXISTS)
public Instant getAttr_NESTED_ATTR_UPDATE_() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

assertThat(result.getId()).isEqualTo("initial_id");

// update id (no annotation, defaults to WRITE_ALWAYS) - should change
result.setId("updated_id");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?

.setChildList(Arrays.asList(
new UpdateBehaviorTestModels.SimpleBeanChild().setId("child1").setAttr("attr_child1"),
new UpdateBehaviorTestModels.SimpleBeanChild().setId("child2").setAttr("attr_child2")));
initial.setId("initial_id");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we first set the id to 1 , then initial_id, is there a particular reason or just a typo ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants