From 4c47e688a18dd07bff70f523954e6e8d602525e5 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal <36329474+rpanackal@users.noreply.github.com> Date: Mon, 23 Dec 2024 15:11:29 +0100 Subject: [PATCH] OData V2 Nested Field Update (#675) Co-authored-by: Roshin Rajan Panackal --- .../client/request/ODataRequestBatch.java | 14 +- .../client/request/ODataRequestUpdate.java | 13 +- .../odata/client/request/UpdateStrategy.java | 27 +++- .../FluentHelperUpdateToRequestTest.java | 30 +++- .../odata/helper/FluentHelperUpdate.java | 68 +++++++-- .../odata/helper/ModifyPatchStrategy.java | 23 +++ .../odata/helper/ODataEntitySerializer.java | 132 +++++++++++++++++- .../helper/ODataEntitySerializerTest.java | 45 +++++- release_notes.md | 3 +- 9 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 datamodel/odata/odata-core/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModifyPatchStrategy.java diff --git a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java index 6438b145a..755c0c53a 100644 --- a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java +++ b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java @@ -278,7 +278,19 @@ public Changeset addUpdate( @Nonnull final ODataRequestUpdate request ) { final String versionIdentifier = request.getVersionIdentifier(); request.addVersionIdentifierToHeaderIfPresent(versionIdentifier); - final String httpMethod = request.getUpdateStrategy() == UpdateStrategy.MODIFY_WITH_PATCH ? "PATCH" : "PUT"; + + final String httpMethod; + switch( request.getUpdateStrategy() ) { + case MODIFY_WITH_PATCH, MODIFY_WITH_PATCH_RECURSIVE_DELTA, MODIFY_WITH_PATCH_RECURSIVE_FULL: + httpMethod = "PATCH"; + break; + case REPLACE_WITH_PUT: + httpMethod = "PUT"; + break; + default: + throw new IllegalStateException("Unexpected update strategy: " + request.getUpdateStrategy()); + } + final BatchItemSingle item = new BatchItemSingle(originalRequest, request, httpMethod, request::getSerializedEntity); queries.add(item); diff --git a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestUpdate.java b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestUpdate.java index aa49175a1..445876765 100644 --- a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestUpdate.java +++ b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestUpdate.java @@ -174,12 +174,13 @@ public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient ) final ODataHttpRequest request = ODataHttpRequest.forHttpEntity(this, httpClient, requestHttpEntity); addVersionIdentifierToHeaderIfPresent(versionIdentifier); - if( updateStrategy == UpdateStrategy.MODIFY_WITH_PATCH ) { - return tryExecuteWithCsrfToken(httpClient, request::requestPatch).get(); - } else if( updateStrategy == UpdateStrategy.REPLACE_WITH_PUT ) { - return tryExecuteWithCsrfToken(httpClient, request::requestPut).get(); - } else { - throw new IllegalStateException("Unexpected update Strategy: " + updateStrategy); + switch( updateStrategy ) { + case MODIFY_WITH_PATCH, MODIFY_WITH_PATCH_RECURSIVE_DELTA, MODIFY_WITH_PATCH_RECURSIVE_FULL: + return tryExecuteWithCsrfToken(httpClient, request::requestPatch).get(); + case REPLACE_WITH_PUT: + return tryExecuteWithCsrfToken(httpClient, request::requestPut).get(); + default: + throw new IllegalStateException("Unexpected update Strategy: " + updateStrategy); } } diff --git a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UpdateStrategy.java b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UpdateStrategy.java index 410da06e6..98ed016ba 100644 --- a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UpdateStrategy.java +++ b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UpdateStrategy.java @@ -3,6 +3,8 @@ */ package com.sap.cloud.sdk.datamodel.odata.client.request; +import com.google.common.annotations.Beta; + /** * The strategy to use when updating existing entities. */ @@ -17,5 +19,28 @@ public enum UpdateStrategy /** * Request to update the entity is sent with the HTTP method PATCH and its payload contains the changed fields only. */ - MODIFY_WITH_PATCH; + MODIFY_WITH_PATCH, + + /** + * Request to update the entity is sent with the HTTP method PATCH and its payload contains the changed fields + * including the changes in nested non-entity type fields. + * + * The request payload contains only the changed fields. Navigation properties are not supported. + * + * @since 5.16.0 + */ + @Beta + MODIFY_WITH_PATCH_RECURSIVE_DELTA, + + /** + * Request to update the entity is sent with the HTTP method PATCH and its payload contains the changed fields + * including the changes in nested non-entity type fields. + * + * The request payload contains the full value of complex fields for changes in any nested field. Navigation + * properties are not supported. + * + * @since 5.16.0 + */ + @Beta + MODIFY_WITH_PATCH_RECURSIVE_FULL; } diff --git a/datamodel/odata/odata-api-sample/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdateToRequestTest.java b/datamodel/odata/odata-api-sample/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdateToRequestTest.java index 9d4d64247..eb136398c 100644 --- a/datamodel/odata/odata-api-sample/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdateToRequestTest.java +++ b/datamodel/odata/odata-api-sample/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdateToRequestTest.java @@ -4,6 +4,7 @@ package com.sap.cloud.sdk.datamodel.odata.helper; +import static com.sap.cloud.sdk.datamodel.odata.helper.ModifyPatchStrategy.*; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; @@ -19,7 +20,6 @@ import org.apache.http.HttpVersion; import org.apache.http.client.HttpClient; import org.apache.http.message.BasicHttpResponse; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; @@ -211,13 +211,12 @@ void testUpdateBPatchUpdateNull() } @Test - @Disabled( " Test is failing as the getChangedFields() method on Complex Type is not working as expected." ) - void testUpdatePatchComplexProperty() + void testUpdatePatchComplexPropertyDelta() { final ProductCount count1 = ProductCount.builder().productId(123).quantity(10).build(); final Receipt receipt = Receipt.builder().id(1001).customerId(9001).productCount1(count1).build(); - final String expectedSerializedEntity = "{\"ProductCount1\":{\"Quantity\":\"20\"}}"; + final String expectedSerializedEntity = "{\"ProductCount1\":{\"Quantity\":20}}"; count1.setQuantity(20); @@ -225,7 +224,28 @@ void testUpdatePatchComplexProperty() FluentHelperFactory .withServicePath(ODATA_ENDPOINT_URL) .update(ENTITY_COLLECTION, receipt) - .modifyingEntity() + .modifyingEntity(RECURSIVE_DELTA) + .toRequest(); + + assertThat(receiptUpdate).isNotNull(); + assertThat(receiptUpdate.getSerializedEntity()).isEqualTo(expectedSerializedEntity); + } + + @Test + void testUpdatePatchComplexPropertyFull() + { + final ProductCount count1 = ProductCount.builder().productId(123).quantity(10).build(); + final Receipt receipt = Receipt.builder().id(1001).customerId(9001).productCount1(count1).build(); + + final String expectedSerializedEntity = "{\"ProductCount1\":{\"ProductId\":123,\"Quantity\":20}}"; + + count1.setQuantity(20); + + final ODataRequestUpdate receiptUpdate = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .update(ENTITY_COLLECTION, receipt) + .modifyingEntity(RECURSIVE_FULL) .toRequest(); assertThat(receiptUpdate).isNotNull(); diff --git a/datamodel/odata/odata-core/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java b/datamodel/odata/odata-core/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java index b884b3a20..91b7440ac 100644 --- a/datamodel/odata/odata-core/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java +++ b/datamodel/odata/odata-core/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java @@ -14,6 +14,7 @@ import org.apache.http.client.HttpClient; +import com.google.common.annotations.Beta; import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpClientAccessor; import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; @@ -144,23 +145,31 @@ private String getSerializedEntity() { final EntityT entity = getEntity(); try { + final List fieldsToExcludeUpdate = + excludedFields + .stream() + .map(EntitySelectable::getFieldName) + .map(FieldReference::of) + .collect(Collectors.toList()); + + final List fieldsToIncludeInUpdate = + includedFields + .stream() + .map(EntitySelectable::getFieldName) + .map(FieldReference::of) + .collect(Collectors.toList()); + switch( updateStrategy ) { case REPLACE_WITH_PUT: - final List fieldsToExcludeUpdate = - excludedFields - .stream() - .map(EntitySelectable::getFieldName) - .map(FieldReference::of) - .collect(Collectors.toList()); return ODataEntitySerializer.serializeEntityForUpdatePut(entity, fieldsToExcludeUpdate); case MODIFY_WITH_PATCH: - final List fieldsToIncludeInUpdate = - includedFields - .stream() - .map(EntitySelectable::getFieldName) - .map(FieldReference::of) - .collect(Collectors.toList()); - return ODataEntitySerializer.serializeEntityForUpdatePatch(entity, fieldsToIncludeInUpdate); + return ODataEntitySerializer.serializeEntityForUpdatePatchShallow(entity, fieldsToIncludeInUpdate); + case MODIFY_WITH_PATCH_RECURSIVE_DELTA: + return ODataEntitySerializer + .serializeEntityForUpdatePatchRecursiveDelta(entity, fieldsToIncludeInUpdate); + case MODIFY_WITH_PATCH_RECURSIVE_FULL: + return ODataEntitySerializer + .serializeEntityForUpdatePatchRecursiveFull(entity, fieldsToIncludeInUpdate); default: throw new IllegalStateException("Unexpected update strategy:" + updateStrategy); } @@ -193,7 +202,6 @@ private String getSerializedEntity() * * @param fields * The fields to be included in the update execution. - * * @return The same fluent helper which will include the specified fields in an update request. */ @Nonnull @@ -212,7 +220,6 @@ public final FluentHelperT includingFields( @Nonnull final EntitySelectable entity ) * @return The serialized JSON string for entity update request. */ @Nonnull - static String serializeEntityForUpdatePatch( + static String serializeEntityForUpdatePatchShallow( @Nonnull final VdmEntity entity, @Nonnull final Collection includedFields ) { @@ -122,6 +122,136 @@ static String serializeEntityForUpdatePatch( return GSON_SERIALIZING_NULLS.toJson(partialEntity); } + /** + * Serializes an entity for update request (PATCH) including changes in nested properties. Allowing null values. + * Resulting JSON contains the full value of complex fields for changing any nested field. + * + * @param entity + * The OData V2 entity reference. + * @param includedFields + * Collection of fields to be included in the update (PATCH) request. + * @return The serialized JSON string for entity update request. + */ + @Nonnull + static String serializeEntityForUpdatePatchRecursiveFull( + @Nonnull final VdmEntity entity, + @Nonnull final Collection includedFields ) + { + final JsonObject fullEntityJson = GSON_SERIALIZING_NULLS.toJsonTree(entity).getAsJsonObject(); + final JsonObject patchObject = new JsonObject(); + + final Set changedFieldNames = new HashSet<>(entity.getChangedFields().keySet()); + includedFields.stream().map(FieldReference::getFieldName).forEach(changedFieldNames::add); + changedFieldNames.forEach(key -> patchObject.add(key, fullEntityJson.get(key))); + + entity + .toMapOfFields() + .entrySet() + .stream() + .filter(entry -> !patchObject.has(entry.getKey())) + .filter(entry -> containsNestedChangedFields(entry.getValue())) + .forEach(entry -> patchObject.add(entry.getKey(), fullEntityJson.get(entry.getKey()))); + + log.debug("The following object is serialized for update : {}.", patchObject); + + return GSON_SERIALIZING_NULLS.toJson(patchObject); + } + + /** + * Checks if the given complex object contains any changed fields in its nested fields. + * + * @param obj + * the complex object to check + * @return true if the complex object contains any changed fields, false otherwise + */ + private static boolean containsNestedChangedFields( final Object obj ) + { + if( obj instanceof VdmComplex vdmComplex ) { + if( !vdmComplex.getChangedFields().isEmpty() ) { + return true; + } + for( final Object complexField : vdmComplex.toMapOfFields().values() ) { + if( containsNestedChangedFields(complexField) ) { + return true; + } + } + } + return false; + } + + /** + * Serializes an entity for update request (PATCH) including changes in nested properties. Allowing null values. + * Resulting JSON contains only the changed fields (including nested changes). + * + * @param entity + * The OData V2 entity reference. + * @param includedFields + * Collection of fields to be included in the update (PATCH) request. + * @return The serialized JSON string for entity update request. + */ + @Nonnull + static String serializeEntityForUpdatePatchRecursiveDelta( + @Nonnull final VdmEntity entity, + @Nonnull final Collection includedFields ) + { + final JsonObject fullEntityJson = GSON_SERIALIZING_NULLS.toJsonTree(entity).getAsJsonObject(); + final JsonObject patchObject = new JsonObject(); + + // Recursively build patch object from changed fields + final JsonObject tempPatchObject = createPatchObjectRecursiveDelta(entity, fullEntityJson); + + // Add included fields (from the root only) + includedFields + .stream() + .map(FieldReference::getFieldName) + .forEach(key -> patchObject.add(key, fullEntityJson.get(key))); + + // Merge all fields from the tempPatchObject if not already present + tempPatchObject + .entrySet() + .stream() + .filter(entry -> !patchObject.has(entry.getKey())) + .forEach(entry -> patchObject.add(entry.getKey(), entry.getValue())); + + log.debug("The following delta object is serialized for update : {}.", patchObject); + + return GSON_SERIALIZING_NULLS.toJson(patchObject); + } + + /** + * Recursively builds a patch object for a VdmObject by including only changed fields. Complex fields are traversed + * recursively. + * + * @param vdmObject + * the VdmObject (entity or complex) to build the patch from + * @param jsonObject + * the full JSON representation of this object + * @return a JsonObject that contains only changed fields (including nested changes) + */ + @Nonnull + private static + JsonObject + createPatchObjectRecursiveDelta( @Nonnull final VdmObject vdmObject, @Nonnull final JsonObject jsonObject ) + { + final JsonObject patch = new JsonObject(); + + // Process all complex fields and recursively build patch for the complex field + vdmObject.toMapOfFields().forEach(( fieldName, val ) -> { + if( val instanceof VdmComplex complexField ) { + final var childJsonObject = jsonObject.getAsJsonObject(fieldName); + final var childJsonObjectDelta = createPatchObjectRecursiveDelta(complexField, childJsonObject); + if( !childJsonObjectDelta.isEmpty() ) { + patch.add(fieldName, childJsonObjectDelta); + } + } + }); + + // Add explicitly changed fields + vdmObject.getChangedFields().keySet().forEach(key -> patch.add(key, jsonObject.get(key))); + + return patch; + } + private static void removeVersionIdentifier( @Nonnull final JsonObject jsonObject ) { log.debug("Removing redundant \"versionIdentifier\" recursively from JSON object: {}", jsonObject); diff --git a/datamodel/odata/odata-core/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializerTest.java b/datamodel/odata/odata-core/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializerTest.java index 46e5899ef..0c5d13d55 100644 --- a/datamodel/odata/odata-core/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializerTest.java +++ b/datamodel/odata/odata-core/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializerTest.java @@ -59,7 +59,50 @@ void testSerializeEntityForUpdatePatch() entity.setIntegerValue(42); final Collection fields = Arrays.asList(FieldReference.of("a"), FieldReference.of("b")); - final String payload = ODataEntitySerializer.serializeEntityForUpdatePatch(entity, fields); + final String payload = ODataEntitySerializer.serializeEntityForUpdatePatchShallow(entity, fields); assertThat(payload).isEqualTo("{\"a\":null,\"b\":null,\"IntegerValue\":42}"); } + + @Test + void testSerializeEntityForUpdatePatchNested() + { + final TestVdmComplex grandchildComplex = TestVdmComplex.builder().someValue("initialGrandchildValue").build(); + final TestVdmComplex childComplex = + TestVdmComplex.builder().someValue("initialChildValue").complexValue(grandchildComplex).build(); + final TestVdmEntity rootEntity = + TestVdmEntity + .builder() + .stringValue("initialRootValue") + .booleanValue(false) + .complexValue(childComplex) + .build(); + + rootEntity.setStringValue("newRootValue"); + grandchildComplex.setSomeValue("newGrandchildValue"); + + final Collection additionalFields = Arrays.asList(FieldReference.of("customField")); + + final String fullPayload = + ODataEntitySerializer.serializeEntityForUpdatePatchRecursiveFull(rootEntity, additionalFields); + assertThat(fullPayload) + .isEqualTo( + "{\"StringValue\":\"newRootValue\",\"customField\":null,\"ComplexValue\":{\"SomeValue\":\"initialChildValue\",\"OtherValue\":null,\"ComplexValue\":{\"SomeValue\":\"newGrandchildValue\",\"OtherValue\":null,\"ComplexValue\":null}}}"); + + final String deltaPayload = + ODataEntitySerializer.serializeEntityForUpdatePatchRecursiveDelta(rootEntity, additionalFields); + assertThat(deltaPayload) + .isEqualTo( + "{\"customField\":null,\"ComplexValue\":{\"ComplexValue\":{\"SomeValue\":\"newGrandchildValue\"}},\"StringValue\":\"newRootValue\"}"); + + final TestVdmComplex siblingGrandchildComplex = + TestVdmComplex.builder().someValue("newSiblingGrandchildValue").build(); + childComplex.setComplexValue(siblingGrandchildComplex); + + final String siblingDeltaPayload = + ODataEntitySerializer.serializeEntityForUpdatePatchRecursiveDelta(rootEntity, additionalFields); + assertThat(siblingDeltaPayload) + .isEqualTo( + "{\"customField\":null,\"ComplexValue\":{\"ComplexValue\":{\"SomeValue\":\"newSiblingGrandchildValue\",\"OtherValue\":null,\"ComplexValue\":null}},\"StringValue\":\"newRootValue\"}"); + } + } diff --git a/release_notes.md b/release_notes.md index 3112514a9..2bef66a86 100644 --- a/release_notes.md +++ b/release_notes.md @@ -12,7 +12,8 @@ ### ✨ New Functionality -- +- Add experimental support for updating nested fields in OData V2 complex types via PATCH requests + - Use optional argument `FluentHelperUpdate#modifyingEntity( ModifyPatchStrategy )` to control updates with delta or full complex property payloads. ### 📈 Improvements