Skip to content

Support update expressions in single request update#6471

Open
anasatirbasa wants to merge 11 commits intoaws:masterfrom
anasatirbasa:feature/support-update-expressions-in-single-request-update
Open

Support update expressions in single request update#6471
anasatirbasa wants to merge 11 commits intoaws:masterfrom
anasatirbasa:feature/support-update-expressions-in-single-request-update

Conversation

@anasatirbasa
Copy link
Copy Markdown
Contributor

@anasatirbasa anasatirbasa commented Oct 13, 2025

Motivation and Context

#5554

Enhanced UpdateItem and transact-update requests can now carry a request-level UpdateExpression. That expression is merged with updates coming from the mapped item (POJO) and from beforeWrite extensions into the single update expression DynamoDB sees.

Merge strategies

LEGACY (default, backward-compatible)

  • Preserves existing behavior.
  • Concatenates actions from POJO, extension, and request sources.
  • Does not broadly resolve overlapping paths.
  • Overlapping updates may still be rejected by DynamoDB (Two document paths overlap).
  • The existing null-field behavior remains: if the POJO would emit a REMOVE for an attribute but that attribute also appears in an extension or request expression, the REMOVE is skipped so remove and set do not fight on the same name.

PRIORITIZE_HIGHER_SOURCE (opt-in)

  • Enables conflict-aware merging using resolved document paths and DynamoDB-style path overlap:
    • Paths overlap if they are equal, or one extends the other at a map segment (.) or list index ([).
    • No overlap between sibling map keys (e.g. profile.name vs profile.city) or between different list indices (e.g. items[0] vs items[1]).
    • Overlap between a parent and child path (e.g. profile vs profile.name, or items vs items[0]).
  • When paths overlap, precedence is: request > extension > POJO.
  • Merge steps:
    1. Keep all request actions.
    2. Keep extension actions that do not overlap any request path.
    3. Keep POJO actions that do not overlap any path already kept from request or from extension (step 2 only).
      Extension actions dropped in step 2 are not considered when filtering POJO actions in step 3.

Path overlap examples (for PRIORITIZE_HIGHER_SOURCE)

  • profile.name and profile.cityno overlap (both may appear).
  • profile and profile.nameoverlap (lower-priority action on that path is dropped).
  • items[0] and items[1]no overlap (both may appear).
  • Two actions on items[0]overlap (higher-priority source wins for that path).

If updateExpressionMergeStrategy is not set, LEGACY is used.


What Changed

API

  • UpdateItemEnhancedRequest / TransactUpdateItemEnhancedRequest: updateExpression, updateExpressionMergeStrategy.

Implementation

  • UpdateItemOperation merges sources through UpdateExpressionResolver, then UpdateExpressionConverter builds the low-level UpdateItemRequest.
  • Transact updates reuse generateRequest(), so TransactWriteItem.update() uses the same updateExpression, expressionAttributeNames, and expressionAttributeValues as the non-transact path.

Validation

  • If there are non-key attributes from the item (POJO SET/REMOVE generation), UpdateExpressionResolver.Builder requires tableMetadata at build time.

Testing

Resolver and merge logic

  • UpdateExpressionResolverTest (and related update-expression utilities) exercises LEGACY concatenation, PRIORITIZE_HIGHER_SOURCE overlap and precedence, path resolution (including expression name placeholders), and build validation when POJO-derived updates need table metadata.

Unit tests

UpdateItemOperationTest — Asserts the merged result of generateRequest() for UpdateItemEnhancedRequest:

  • Request supplies the only update expression (no extension; minimal item).
  • Extension + request: LEGACY and PRIORITIZE with non-overlapping paths (e.g. extension DELETE vs request SET).
  • Extension + request: PRIORITIZE with the same path (request wins; extension dropped on that path).
  • Extension + request: LEGACY with the same path (both actions chained; documents LEGACY semantics).
  • POJO (FakeItemWithSort with non-key attributes) + request and/or extension under LEGACY (all sources merged).
  • POJO + request or POJO + extension under PRIORITIZE (overlap: higher source wins; non-overlapping POJO attributes still present).
  • POJO + extension + request under PRIORITIZE (full precedence: request, then non-conflicting extension, then remaining POJO paths).

UpdateItemOperationTransactTest — Same scenarios using TransactUpdateItemEnhancedRequest, with two checks per case:

  1. generateRequest() output matches expectations.
  2. generateTransactWriteItem() output matches that same request on updateExpression and expression attribute maps (parity with the transact Update payload).

Functional tests

  • UpdateExpressionTest covers end-to-end enhanced client flows for representative path and merge cases, consistent with the overlap rules documented on UpdateExpressionMergeStrategy.

Test Results

  • Existing tests continue to pass (backward compatibility preserved)
  • New and updated tests pass for both strategies and all covered scenarios

Test Coverage on modified classes:

image

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() [ ] Not applicable for UpdateExpression feature
d. StaticTableSchema.builder(Customer.class) [x]
2. Nesting of Different TableSchema Types
a. @DynamoDbBean with annotated auto-generated key [x]
b. @DynamoDbImmutable with annotated auto-generated key [x]
c. Auto-generated key combined with partition/sort key [x]
3. CRUD Operations
a. scan() [x]
b. query() [ ] Not directly tested with UpdateExpression
c. updateItem() preserves existing value or generates when absent [x]
d. putItem() with no key set (auto-generation occurs) [x]
e. putItem() with key set manually [x]
f. getItem() retrieves auto-generated key [x]
g. deleteItem() [x]
h. batchGetItem() [x]
i. batchWriteItem() [x]
j. transactGetItems() [x]
k. transactWriteItems() [x]
4. Data Types and Null Handling
a. Annotated attribute is null → key auto-generated [x]
b. Annotated attribute non-null → value preserved [x]
c. Validation rejects non-String annotated attribute [x]
5. AsyncTable and SyncTable
a. DynamoDbAsyncTable Testing [ ] Focus was on sync operations
b. DynamoDbTable Testing [x]
6. New/Modification in Extensions
a. Works with other extensions like VersionedRecordExtension [x]
b. Test with Default Values in Annotations [ ] Not applicable for UpdateExpression
c. Combination of Annotation and Builder passes extension [ ] Not applicable for UpdateExpression
7. New/Modification in Converters
a. Tables with Scenario in Scenario No.1 (All table schemas are Must) [ ] Not applicable for UpdateExpression
b. Test with Default Values in Annotations [ ] Not applicable for UpdateExpression
c. Test All Scenarios from 1 to 5 [ ] Not applicable for UpdateExpression

Types of changes

  • 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 changed the title Feature/support update expressions in single request update Support update expressions in single request update Oct 20, 2025
@anasatirbasa anasatirbasa force-pushed the feature/support-update-expressions-in-single-request-update branch 2 times, most recently from bfa9f26 to a7fe576 Compare October 22, 2025 06:42
@anasatirbasa anasatirbasa marked this pull request as ready for review October 27, 2025 15:09
@anasatirbasa anasatirbasa requested a review from a team as a code owner October 27, 2025 15:09
@anasatirbasa anasatirbasa force-pushed the feature/support-update-expressions-in-single-request-update branch 2 times, most recently from f26a4ec to 8791c1f Compare December 15, 2025 10:05
@bhoradc bhoradc mentioned this pull request Jan 6, 2026
12 tasks
@anasatirbasa anasatirbasa force-pushed the feature/support-update-expressions-in-single-request-update branch from ef8f4c0 to a0eeea7 Compare January 11, 2026 14:53
@anasatirbasa anasatirbasa force-pushed the feature/support-update-expressions-in-single-request-update branch from 716b7c8 to dabb912 Compare March 9, 2026 00:39
.ignoreNulls(ignoreNulls)
.ignoreNullsMode(ignoreNullsMode)
.conditionExpression(conditionExpression)
.returnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should we add updateExpression here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, it should be added to the buillder. I've made the changes.

* request expressions (highest).
*
* <p><b>Steps:</b> Identify attributes used by extensions/requests to prevent REMOVE conflicts →
* create item SET/REMOVE actions → merge extensions (override item) → merge request (override all).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is not happening, this concatenates and DynamoDB will throw a conflict error, see updateExpressionInRequest_whenAttributeAlsoInPojo_shouldThrowConflictError test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The referenced test is no longer present because the implementation was changed to add an opt-in flag (updateExpressionMergeStrategy) in order to preserve backward compatibility.

Default behavior remains LEGACY: actions are concatenated and overlapping paths may still fail in DynamoDB (Two document paths overlap).

Conflict resolution is applied only when users opt into PRIORITIZE_HIGHER_SOURCE (request > extension > POJO).

The javadoc was updated and additional details are captured here.

.collect(Collectors.toSet());
}

public static UpdateExpression generateItemSetExpression(Map<String, AttributeValue> itemMap,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should this be package private as it is used only by unit tests?
Same for generateItemRemoveExpression.

Copy link
Copy Markdown
Contributor Author

@anasatirbasa anasatirbasa Mar 12, 2026

Choose a reason for hiding this comment

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

The two methods were moved to UpdateExpressionUtils to centralize shared update-expression construction logic and keep the resolver focused on merge behavior.

Thay were also made package-private, since they are just used by UpdateExpressionResolver and tests in the same package.

.build();
}

public static UpdateExpression generateItemRemoveExpression(Map<String, AttributeValue> itemMap,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does this need to be public ?

Copy link
Copy Markdown
Contributor Author

@anasatirbasa anasatirbasa Mar 12, 2026

Choose a reason for hiding this comment

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

The method was moved to UpdateExpressionUtils to centralize shared update-expression construction logic and keep the resolver focused on merge behavior.

This was also made package-private, since it's only used by UpdateExpressionResolver and tests in the same package.

.collect(Collectors.toSet());
}

public static UpdateExpression generateItemSetExpression(Map<String, AttributeValue> itemMap,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does this need to be public ?

Copy link
Copy Markdown
Contributor Author

@anasatirbasa anasatirbasa Mar 12, 2026

Choose a reason for hiding this comment

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

The method was moved to UpdateExpressionUtils to centralize shared update-expression construction logic and keep the resolver focused on merge behavior.

This was also made package-private, since it's only used by UpdateExpressionResolver and tests in the same package.

}

public UpdateExpressionResolver build() {
return new UpdateExpressionResolver(this);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If nonKeyAttributes() is never called on the builder, this.nonKeyAttributes remains null. The resolve() method calls nonKeyAttributes.isEmpty() which will throw NullPointerException.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Null handling was added in Builder.nonKeyAttributes(...) to set Collections.emptyMap() when null is passed, so resolve() wil not throw NullPointerException:

public Builder nonKeyAttributes(Map<String, AttributeValue> nonKeyAttributes) {
   if (nonKeyAttributes == null) {
        this.nonKeyAttributes = Collections.emptyMap();
   } else {
        this.nonKeyAttributes = Collections.unmodifiableMap(new HashMap<>(nonKeyAttributes));
    }
    return this;
    }

}

}
} No newline at end of file
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We don't have new line at the end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added new line.

* if there are attributes to be updated (most likely). If both exist, they are merged and the code generates a final
* Expression that represent the result.
* Merges UpdateExpressions from three sources in priority order: POJO attributes (lowest),
* extensions (medium), request (highest). Higher priority sources override conflicting actions.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We may want to clarify the javadoc here, If I understand this right,

Given a POJO with: item.setCounter(100L)
And a request UpdateExpression: SET counter = counter + :inc

The resolver produces BOTH actions (concat, not override):
SET #AMZN_MAPPED_counter = :AMZN_MAPPED_counter, counter = counter + :inc

DynamoDB rejects this with: "Two document paths overlap with each other"

The only conflict that IS auto-resolved: if item.setCounter(null), the POJO would generate REMOVE counter, but the resolver suppresses that REMOVE because counter appears in the request expression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The implementation and javadoc were updated and details related to the new approach are captured here.

* Tests transactWriteItems() operation Verifies that transactional write operations work correctly.
*/
@Test
public void transactWriteItems_withUpdateExpression() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This test does NOT exercise TransactUpdateItemEnhancedRequest.updateExpression(), it only performs transactional delete + put operations. There is no test that verifies the request-level
UpdateExpression flows through the TransactUpdateItemEnhancedRequest → UpdateItemOperation →
UpdateExpressionResolver path (the right-side of the Either).

A test using:

  .addUpdateItem(mappedTable, TransactUpdateItemEnhancedRequest.builder(...)
      .item(keyRecord).updateExpression(expression).build())

is needed to cover this code path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The referenced test is no longer present, it was replaced during the refactor.

This scenario is covered by a new test: transactWriteItems_givenTransactUpdateWithRequestExpression_whenExecute_thenListElementUpdated, which uses TransactUpdateItemEnhancedRequest.updateExpression(...) via addUpdateItem(...) and verifies the update is persisted end to end.

@anasatirbasa
Copy link
Copy Markdown
Contributor Author

The available approaches were analyzed and the main goal was to avoid breaking backward compatibility.
So instead of changing behavior globally, an opt-in flag was added: updateExpressionMergeStrategy.

If this is not set, behavior stays LEGACY (same as today).
In LEGACY, pojo, extension, and request update actions are concatenated as-is. There’s no broad conflict resolution for overlapping paths, so combinations like list and list[0] can still be rejected by DynamoDB with "Two document paths overlap".

The one existing exception is null-remove suppression: if a POJO field is null, it would normally produce a REMOVE, but that REMOVE is skipped when the same attribute is explicitly updated by an extension or request expression. This existing protective behavior is unchanged.

If users want conflict handling, they can opt into PRIORITIZE_HIGHER_SOURCE.
When multiple sources touch the same top-level attribute, we choose one winner with this order:
request expression > extension expression > POJO

Examples:

  • list, list[0], and list[1] are all paths under the same top-level attribute: list
  • object.list[0] is under top-level object
    So when conflicts are resolved, the first three compete in the same list bucket, while object.list[0] is handled separately.

Tha Javadoc was also updated to reflect all of this: why the flag exists, default behavior, available values, precedence, and path grouping.

Tests were updated and expanded to cover these scenarios end-to-end, including:

  • legacy default behavior
  • opt-in precedence behavior
  • overlapping paths (list, list[0], list[1])
  • nested paths (object.list[0])
  • null-remove suppression in legacy mode
  • non-overlapping attributes merging as expected.

*
* @return A list of top level attribute names that have update actions associated.
*/
public static List<String> findAttributeNames(UpdateExpression updateExpression) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

findAttributeNames can return duplicate entries for the same attribute, which causes incorrect grouping in PRIORITIZE_HIGHER_SOURCE mode.

The method combines two lists:

  • listPathsWithoutTokens — collects action paths that don't contain #
  • listAttributeNamesFromTokens — collects values from the merged expressionNames map

An action with a raw path (no #) that also carries an expressionNames entry for that same attribute contributes to both lists. Simple example:

SetAction action = SetAction.builder()
    .path("counter")                          // raw path → included by listPathsWithoutTokens
    .value(":v")
    .putExpressionName("#counter", "counter") // value "counter" → included by listAttributeNamesFromTokens
    .putExpressionValue(":v", AttributeValue.builder().n("1").build())
    .build();

UpdateExpression expr = UpdateExpression.builder().addAction(action).build();

UpdateExpressionConverter.findAttributeNames(expr);
// → ["counter", "counter"]  ← same attribute counted twice

In mergeBySourcePriority, the result is wrapped in new HashSet<>(...) which masks the duplicate, but any caller that iterates the list directly (or uses it for counting) will get wrong results. The existing test findAttributeNames_noComposedNames_duplicates already documents this behaviour, which means the contract is broken by design.

Suggested fix — deduplicate inside findAttributeNames before returning:

public static List<String> findAttributeNames(UpdateExpression updateExpression) {
    if (updateExpression == null) {
        return Collections.emptyList();
    }
    List<String> attributeNames = listPathsWithoutTokens(updateExpression);
    List<String> attributeNamesFromTokens = listAttributeNamesFromTokens(updateExpression);
    attributeNames.addAll(attributeNamesFromTokens);
    // Deduplicate: a raw path and its expressionNames entry can both resolve to the same attribute name
    return attributeNames.stream().distinct().collect(Collectors.toList());
}

This makes the method's contract match its Javadoc ("find the list of attribute names that will be updated") and removes the silent dependency on callers wrapping the result in a Set. The test findAttributeNames_noComposedNames_duplicates should be updated to assert ["attribute1"] instead of ["attribute1", "attribute1"].

findAttributeNames already had this bug before this PR but was never triggered because it was only called with SDK-internal extension expressions (which always use # token paths). This PR is the first to call it with user-provided request expressions, where raw paths and expressionNames entries can coexist for the same attribute, making the bug reachable. The fix belongs in findAttributeNames itself, but the root cause was introduced in PR #3036.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The suggested fix was applied - keeping only the distinct attribute names:

    public static List<String> findAttributeNames(UpdateExpression updateExpression) {
        ...
        return attributeNames.stream().distinct().collect(Collectors.toList());
    }

Updated javadoc and fixed tests:

  @Test
    void findAttributeNames_noComposedNames_duplicates() {
        ...
        assertThat(attributes).containsExactly("attribute1");
    }
@Test
  void findAttributeNames_composedNames_duplicates() {
       ...
      assertThat(attributes).containsExactly("attribute1");
  }

Comment on lines +137 to +141
/**
* For {@link UpdateExpressionMergeStrategy#PRIORITIZE_HIGHER_SOURCE}: assigns each top-level attribute name to at most one
* source by priority (request, then extension, then POJO), then keeps only that source's actions for each assigned name.
*/
private static UpdateExpression mergeBySourcePriority(UpdateExpression itemExpression,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Item in DynamoDB: { "profile": { "name": "Alice", "city": "London" } }

Extension sets:  profile.name  → "Bob"     (e.g. an audit/normalisation extension)
Request sets:    profile.city  → "Paris"   (user wants to update only the city)

Both paths share top-level name profile. mergeBySourcePriority assigns profile to the request (highest priority), then does extensionOwned.removeAll(requestOwned) so profile is removed from extensionOwned. The extension's profile.name action is then filtered out by filterByAttributes. Only profile.city = "Paris" is sent to DynamoDB.

Result: profile.name silently stays "Alice" instead of being updated to "Bob". DynamoDB would have accepted both actions without any conflict profile.name and profile.city are completely independent paths.

Details:
PRIORITIZE_HIGHER_SOURCE groups actions by top-level attribute name and keeps only the highest-priority source's actions for each name. This is too coarse: it drops lower-priority actions even when their specific paths don't actually overlap with the higher-priority source's paths.

Example: extension sets profile.name, request sets profile.city. Both resolve to top-level name profile, so the extension's action is dropped but DynamoDB would accept both in the same request without any conflict, since profile.name and profile.city are independent paths.

True DynamoDB path overlap only occurs when one path is a prefix of another (e.g. profile vs profile.name, or list vs list[0]). Sibling paths like profile.name and profile.city do not overlap.

Suggested fix: replace the top-level-name grouping with a prefix check. For each lower-priority action, only drop it if a higher-priority action's path is a prefix of it (or vice versa):

private static boolean conflictsWith(String candidatePath, Set<String> higherPriorityPaths) {
    for (String higher : higherPriorityPaths) {
        // overlap: one path is a prefix of the other
        if (candidatePath.equals(higher)
            || candidatePath.startsWith(higher + ".")
            || candidatePath.startsWith(higher + "[")
            || higher.startsWith(candidatePath + ".")
            || higher.startsWith(candidatePath + "[")) {
            return true;
        }
    }
    return false;
}

Then in mergeBySourcePriority, instead of removing whole top-level names from extensionOwned/itemOwned, filter individual actions by whether their resolved full path conflicts with any higher-priority action's full path. This preserves non-conflicting sibling actions from lower-priority sources while still correctly resolving true overlaps.

Note: the current Javadoc on UpdateExpressionMergeStrategy.PRIORITIZE_HIGHER_SOURCE and the resolve() method should also be updated to accurately describe whichever behavior is chosen, so users can reason about what will and won't be sent to DynamoDB.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The suggested changes were implemented:

  • Real overlap: same path, or parent vs child (e.g. parent vs parent.child, items vs items[0]) → keep the higher-priority source (request → extension → item).
  • No overlap: different leaves under the same map (e.g. profile.name vs profile.city) → keep both.
  • Lists: different indices, including POJO + extension + request and paths with # placeholders → keep each non-overlapping index.

New tests were added in:

  • UpdateExpressionTest.java
  • UpdateItemOperationTest.java
  • UpdateExpressionResolverTest.java

Javadocs on UpdateExpressionMergeStrategy, UpdateExpressionResolver.resolve() and related request / operation types were updated to match the changes.

Comment on lines +236 to +238
public UpdateExpressionResolver build() {
return new UpdateExpressionResolver(this);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

tableMetadata has no default value, so if the caller never calls .tableMetadata(...), the field is null. resolve() passes tableMetadata to generateItemSetExpression, which passes it to UpdateBehaviorTag.resolveForAttribute, which will NPE. The requireNonNull guard is only on the setter, it doesn't protect against the setter never being called.

So, it may cause a NPE at runtime with a confusing stack trace instead of a clear IllegalStateException at build time.

You can add validation in build():

public UpdateExpressionResolver build() {
    Validate.paramNotNull(tableMetadata, "tableMetadata");
    return new UpdateExpressionResolver(this);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added validation in UpdateExpressionResolver:

public UpdateExpressionResolver build() {
            if (!nonKeyAttributes.isEmpty()) {
                Validate.paramNotNull(tableMetadata, "tableMetadata");
            }
            return new UpdateExpressionResolver(this);
        }

and tests in UpdateExpressionResolverTest that cover this scenario:

  • build_nonKeyAttributesWithoutTableMetadata_throwsNullPointerException
  • build_emptyNonKeyAttributesWithoutTableMetadata_succeeds


UpdateExpression result = resolver.resolve();

assertNull(result);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: I think existing enhanced client test suite uses AssertJ exclusively. You may consider replacing all assertNull(result) with assertThat(result).isNull().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The tests were updated in order to use Assertj.

}
}
return UpdateExpressionConverter.toExpression(updateExpression);
private Expression generateUpdateExpressionIfExist(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

UpdateItemOperationTest can be updated as part of this PR to add tests for the new updateExpression and updateExpressionMergeStrategy fields flowing through generateRequest()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

UpdateItemOperationTest and UpdateItemOperationTransactTest were updated with the following scenarios:

Scenario Result
Request only Only the request update expression.
Extension + request, different paths (LEGACY or PRIORITIZE with no overlap) The full extension expression and the full request expression—every action from each, on its own paths.
Extension + request, same path (PRIORITIZE_HIGHER_SOURCE) The request side for that path; extension actions that hit the same path are dropped.
Extension + request, same path (LEGACY) Both extension and request actions on that path, all chained together.
POJO + request (LEGACY) Everything from the item map plus everything from the request expression.
POJO + extension (LEGACY) Everything from the item map plus everything from the extension expression.
POJO + extension + request (LEGACY) Item, extension, and request contributions, all merged.
POJO + request (PRIORITIZE_HIGHER_SOURCE, same attribute in both) Request wins on the shared attribute; the item still supplies updates for attributes the request does not take over.
POJO + extension (PRIORITIZE_HIGHER_SOURCE, same attribute in both) Extension wins on the shared attribute; the item still supplies updates for attributes the extension does not take over.
POJO + extension + request (PRIORITIZE_HIGHER_SOURCE) Request actions first; then extension actions that do not collide with the request; then item actions that do not collide with either.
Transact (same setups) Same rules; the merged result is what appears on both the plain UpdateItemRequest and the transact Update payload.

}

@Test
public void resolve_prioritizeHigherSource_ownedAttributesButNoResolvedPathMatches_returnsNull() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The test creates an expression with path #missing but expressionNames = {"#other": "logicalAttr"} — the token #missing has no mapping. This is a malformed expression that would fail at DynamoDB anyway. The test asserts null is returned, but the real-world scenario this is meant to cover is unclear. It's testing an edge case that shouldn't occur in practice and could give false confidence.

Consider either documenting clearly what real scenario this covers, or removing it in favor of a test that covers a realistic edge case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, that test used an invalid expression, so it was removed.

}

@Test
public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoSetsDocumentRootAndRequestSetsNestedField_thenNestedPathPersists() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The test asserts persistedRecord.getObjectAttribute().getCity() equals "originCity", but originCity is the value set during putItem. Since the request expression only sets objectAttribute.name (not objectAttribute.city), and the POJO's objectAttribute is dropped by PRIORITIZE_HIGHER_SOURCE, the city field should remain from the original item. This assertion is correct, but it's only valid because the initial putItem set city = "originCity". If the initial put didn't set city, the assertion would still pass (null == null). The test should explicitly set and verify city to make the assertion meaningful.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a read after the initial put and assert city is "originCity" before the update, so we’re not accidentally passing on null == null:

 @Test
    public void updateItem_givenPrioritizeHigherSourceMerge_whenPojoSetsDocumentRootAndRequestSetsNestedField_thenNestedPathPersists() {
        initClientWithExtensions();
        RecordForUpdateExpressions record = createFullRecord();
        mappedTable.putItem(record);

        RecordForUpdateExpressions afterPut = mappedTable.getItem(record);
        assertThat(afterPut.getObjectAttribute().getCity()).isEqualTo("originCity");

        ...

        assertThat(persistedRecord.getObjectAttribute().getCity()).isEqualTo("originCity");
    }

return this;
}

public UpdateExpressionResolver build() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

missing Javadoc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Javadoc was added.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants