+{
+ private ETagSubmissionStrategy eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ETAG_FROM_ENTITY;
+
+ /**
+ * Instantiates this fluent helper using the given service path and entity collection to send the requests.
+ *
+ * @param servicePath
+ * The service path to direct the requests to.
+ * @param entityCollection
+ * The entity collection to direct the requests to.
+ */
+ public FluentHelperDelete( @Nonnull final String servicePath, @Nonnull final String entityCollection )
+ {
+ super(servicePath, entityCollection);
+ }
+
+ /**
+ * The entity object to be deleted by calling the {@link #executeRequest(Destination)} method.
+ *
+ * @return The entity to be deleted.
+ */
+ @Nonnull
+ protected abstract EntityT getEntity();
+
+ @SuppressWarnings( "unchecked" )
+ @Override
+ @Nonnull
+ protected Class extends EntityT> getEntityClass()
+ {
+ return (Class extends EntityT>) getEntity().getClass();
+ }
+
+ /**
+ * The delete request will ignore any version identifier present on the entity and not send an `If-Match` header.
+ *
+ * Warning: This might lead to a response from the remote system that the `If-Match` header is missing.
+ *
+ * It depends on the implementation of the remote system whether the `If-Match` header is expected.
+ *
+ * @return The same request builder that will not send the `If-Match` header in the update request
+ */
+ @Nonnull
+ public FluentHelperT disableVersionIdentifier()
+ {
+ eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_NO_ETAG;
+ return getThis();
+ }
+
+ /**
+ * The delete request will ignore any version identifier present on the entity and delete the entity, regardless of
+ * any changes on the remote entity.
+ *
+ * Warning: Be careful with this option, as this might overwrite any changes made to the remote
+ * representation of this object.
+ *
+ * @return The same request builder that will ignore the version identifier of the entity to update
+ */
+ @Nonnull
+ public FluentHelperT matchAnyVersionIdentifier()
+ {
+ eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ANY_MATCH_ETAG;
+ return getThis();
+ }
+
+ @Override
+ @Nonnull
+ public ModificationResponse executeRequest( @Nonnull final Destination destination )
+ {
+ final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination);
+
+ final ODataRequestResultGeneric result = toRequest().execute(httpClient);
+
+ return ModificationResponse.of(result, getEntity(), destination);
+ }
+
+ @Override
+ @Nonnull
+ public ODataRequestDelete toRequest()
+ {
+ final EntityT entity = getEntity();
+ final String versionIdentifier =
+ eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier());
+
+ final ODataRequestDelete request =
+ new ODataRequestDelete(
+ getServicePath(),
+ getEntityCollection(),
+ ODataEntityKey.of(entity.getKey(), ODataProtocol.V2),
+ versionIdentifier,
+ ODataProtocol.V2);
+
+ return super.addHeadersAndCustomParameters(request);
+ }
+
+ @Nonnull
+ private String getEntityCollection()
+ {
+ return Option.of(entityCollection).getOrElse(() -> getEntity().getEntityCollection());
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperExecutable.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperExecutable.java
new file mode 100644
index 0000000000..5d7e2a7864
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperExecutable.java
@@ -0,0 +1,38 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException;
+
+/**
+ * Representation of any OData V2 request that can be executed.
+ *
+ * @param
+ * The type of the result entity, if any.
+ */
+public interface FluentHelperExecutable
+{
+ /**
+ * Executes this request.
+ *
+ * @param destination
+ * The target system this request should be issued against.
+ * @return A response according to the query criteria.
+ *
+ * @throws DestinationAccessException
+ * If there is an issue accessing the
+ * {@link com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination}.
+ * @throws HttpClientInstantiationException
+ * If there is an issue creating the {@link HttpClient}.
+ * @throws ODataException
+ * If the OData request execution failed.
+ */
+ @Nullable
+ ResultT executeRequest( @Nonnull final Destination destination );
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFactory.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFactory.java
new file mode 100644
index 0000000000..e88436afed
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFactory.java
@@ -0,0 +1,280 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequest;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Utility class to conveniently create fluent helper instances.
+ */
+@RequiredArgsConstructor( access = AccessLevel.PRIVATE )
+class FluentHelperFactory
+{
+ @Nonnull
+ private final String servicePath;
+
+ static FluentHelperFactory withServicePath( @Nonnull final String servicePath )
+ {
+ return new FluentHelperFactory(servicePath);
+ }
+
+ <
+ FluentHelperT extends FluentHelperByKey, EntityT extends VdmEntity>, SelectableT>
+ FluentHelperByKey
+ readByKey(
+ @Nonnull final Class entityClass,
+ @Nonnull final String entityCollection,
+ @Nonnull final Map key )
+ {
+ return new FluentHelperByKey<>(servicePath, entityCollection)
+ {
+ @Nonnull
+ @Override
+ protected Map getKey()
+ {
+ return key;
+ }
+
+ @Nonnull
+ @Override
+ protected Class getEntityClass()
+ {
+ return entityClass;
+ }
+ };
+ }
+
+ <
+ FluentHelperT extends FluentHelperRead, EntityT extends VdmEntity>, SelectableT>
+ FluentHelperRead
+ read( @Nonnull final Class entityClass, @Nonnull final String entityCollection )
+ {
+ return new FluentHelperRead<>(servicePath, entityCollection)
+ {
+ @Nonnull
+ @Override
+ protected Class getEntityClass()
+ {
+ return entityClass;
+ }
+ };
+ }
+
+ <
+ FluentHelperT extends FluentHelperCreate, EntityT extends VdmEntity>>
+ FluentHelperCreate
+ create( @Nonnull final String entityCollection, @Nonnull final EntityT entity )
+ {
+ return new FluentHelperCreate<>(servicePath, entityCollection)
+ {
+ @Nonnull
+ @Override
+ protected EntityT getEntity()
+ {
+ return entity;
+ }
+
+ @SuppressWarnings( "unchecked" )
+ @Nonnull
+ @Override
+ protected Class getEntityClass()
+ {
+ return (Class) entity.getClass();
+ }
+ };
+ }
+
+ <
+ FluentHelperT extends FluentHelperCreate, EntityT extends VdmEntity>>
+ FluentHelperCreate
+ create( @Nonnull final EntityT entity )
+ {
+ return create(entity.getEntityCollection(), entity);
+ }
+
+ <
+ FluentHelperT extends FluentHelperDelete, EntityT extends VdmEntity>>
+ FluentHelperDelete
+ delete( @Nonnull final String entityCollection, @Nonnull final EntityT entity )
+ {
+ return new FluentHelperDelete<>(servicePath, entityCollection)
+ {
+ @Nonnull
+ @Override
+ protected EntityT getEntity()
+ {
+ return entity;
+ }
+
+ @SuppressWarnings( "unchecked" )
+ @Nonnull
+ @Override
+ protected Class getEntityClass()
+ {
+ return (Class) entity.getClass();
+ }
+ };
+ }
+
+ <
+ FluentHelperT extends FluentHelperDelete, EntityT extends VdmEntity>>
+ FluentHelperDelete
+ delete( @Nonnull final EntityT entity )
+ {
+ return delete(entity.getEntityCollection(), entity);
+ }
+
+ <
+ FluentHelperT extends FluentHelperUpdate, EntityT extends VdmEntity>>
+ FluentHelperUpdate
+ update( @Nonnull final String entityCollection, @Nonnull final EntityT entity )
+ {
+ return new FluentHelperUpdate<>(servicePath, entityCollection)
+ {
+ @Nonnull
+ @Override
+ protected EntityT getEntity()
+ {
+ return entity;
+ }
+
+ @SuppressWarnings( "unchecked" )
+ @Nonnull
+ @Override
+ protected Class getEntityClass()
+ {
+ return (Class) entity.getClass();
+ }
+ };
+ }
+
+ <
+ FluentHelperT extends FluentHelperUpdate, EntityT extends VdmEntity>>
+ FluentHelperUpdate
+ update( @Nonnull final EntityT entity )
+ {
+ return update(entity.getEntityCollection(), entity);
+ }
+
+ <
+ FluentHelperT extends FluentHelperFunction, ObjectT, ResultT>
+ FluentHelperFunction
+ function(
+ @Nonnull final Map parameters,
+ @Nonnull final String functionName,
+ @Nonnull final Class objectClass,
+ @Nonnull final Function requestHandler,
+ @Nonnull final BiFunction, Destination, ResultT> executeHandler )
+ {
+ return new FluentHelperFunction<>(servicePath)
+ {
+ @Nonnull
+ @Override
+ protected Map getParameters()
+ {
+ return parameters;
+ }
+
+ @Nonnull
+ @Override
+ protected String getFunctionName()
+ {
+ return functionName;
+ }
+
+ @Nonnull
+ @Override
+ protected HttpUriRequest createRequest( @Nonnull final URI uri )
+ {
+ return requestHandler.apply(uri);
+ }
+
+ @Nonnull
+ @Override
+ protected Class extends ObjectT> getEntityClass()
+ {
+ return objectClass;
+ }
+
+ @Nullable
+ @Override
+ public ResultT executeRequest( @Nonnull final Destination destination )
+ {
+ return executeHandler.apply(this, destination);
+ }
+
+ /**
+ * {@inheritDoc} The special treatment for SAP S/4 HANA OData v2 responses is considered.
+ */
+ @Override
+ @Nullable
+ protected JsonElement refineJsonResponse( @Nullable JsonElement jsonElement )
+ {
+ if( jsonElement instanceof JsonObject && ((JsonObject) jsonElement).has(getFunctionName()) ) {
+ jsonElement = ((JsonObject) jsonElement).get(getFunctionName());
+ }
+ return super.refineJsonResponse(jsonElement);
+ }
+ };
+ }
+
+ <
+ FluentHelperT extends FluentHelperFunction, ObjectT>
+ FluentHelperFunction
+ functionSinglePost(
+ @Nonnull final Map parameters,
+ @Nonnull final String functionName,
+ @Nonnull final Class objectClass )
+ {
+ return function(parameters, functionName, objectClass, HttpPost::new, FluentHelperFunction::executeSingle);
+ }
+
+ <
+ FluentHelperT extends FluentHelperFunction, ObjectT>
+ FluentHelperFunction
+ functionSingleGet(
+ @Nonnull final Map parameters,
+ @Nonnull final String functionName,
+ @Nonnull final Class objectClass )
+ {
+ return function(parameters, functionName, objectClass, HttpGet::new, FluentHelperFunction::executeSingle);
+ }
+
+ <
+ FluentHelperT extends FluentHelperFunction>, ObjectT>
+ FluentHelperFunction>
+ functionMultiplePost(
+ @Nonnull final Map parameters,
+ @Nonnull final String functionName,
+ @Nonnull final Class objectClass )
+ {
+ return function(parameters, functionName, objectClass, HttpPost::new, FluentHelperFunction::executeMultiple);
+ }
+
+ <
+ FluentHelperT extends FluentHelperFunction>, ObjectT>
+ FluentHelperFunction>
+ functionMultipleGet(
+ @Nonnull final Map parameters,
+ @Nonnull final String functionName,
+ @Nonnull final Class objectClass )
+ {
+ return function(parameters, functionName, objectClass, HttpGet::new, FluentHelperFunction::executeMultiple);
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFunction.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFunction.java
new file mode 100644
index 0000000000..a5df8cd654
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFunction.java
@@ -0,0 +1,243 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequest;
+
+import com.google.gson.JsonElement;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataFunctionParameters;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestAction;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestFunction;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric;
+
+import io.vavr.Lazy;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Representation of any OData function import as a fluent interface for further configuring the request and
+ * {@link #executeRequest(Destination) executing} it.
+ *
+ * @param
+ * The fluent helper type.
+ * @param
+ * The type of the object this OData request operates on, if any.
+ * @param
+ * The type of the result entity, if any.
+ */
+@Slf4j
+public abstract class FluentHelperFunction
+ extends
+ FluentHelperBasic
+{
+ /**
+ * Upper-case HTTP method derived from {@link #createRequest(URI)} method.
+ */
+ @SuppressWarnings( "this-escape" )
+ private final Lazy httpMethod = Lazy.of(() -> createRequest(URI.create("")).getMethod().toUpperCase());
+
+ /**
+ * Instantiates this fluent helper using the given service path to send the requests.
+ *
+ * @param servicePath
+ * The service path to direct the requests to.
+ */
+ public FluentHelperFunction( @Nonnull final String servicePath )
+ {
+ super(servicePath, null);
+ }
+
+ /**
+ * Getter for the map of parameters to be used in the function call.
+ *
+ * The map maps the ODataName of a parameter to the corresponding unserialized value.
+ *
+ * Only literal values are allowed as value. No complex or entity objects.
+ *
+ * @return A map containing the parameter for the function call.
+ */
+ @Nonnull
+ protected abstract Map getParameters();
+
+ /**
+ * The exact name of the function to be called on the OData Endpoint.
+ *
+ * @return The function name on the endpoint.
+ */
+ @Nonnull
+ protected abstract String getFunctionName();
+
+ /**
+ * Creates a request for this function based on the given {@code URI}.
+ *
+ * Examples for such requests are {@code HttpGet} and {@code HttpPost}.
+ *
+ * @param uri
+ * The {@code URI} the request should target.
+ * @return The instantiated request.
+ */
+ @Nonnull
+ protected abstract HttpUriRequest createRequest( @Nonnull final URI uri );
+
+ /**
+ * {@inheritDoc}
+ *
+ * The function import arguments are encoded as HTTP query expressions.
+ */
+ @Nonnull
+ @Override
+ public ODataRequestGeneric toRequest()
+ {
+ final ODataRequestGeneric functionImportRequest = instantiateRequest();
+ return super.addHeadersAndCustomParameters(functionImportRequest);
+ }
+
+ @Nonnull
+ private ODataRequestGeneric instantiateRequest()
+ {
+ final String servicePath = getServicePath();
+ final ODataResourcePath resourcePath = ODataResourcePath.of(getFunctionName());
+
+ // get and encode function import parameters as query expressions
+ final String encodedQuery = ODataFunctionParameters.of(getParameters(), ODataProtocol.V2).toEncodedString();
+
+ switch( httpMethod.get() ) {
+ case HttpGet.METHOD_NAME:
+ return new ODataRequestFunction(servicePath, resourcePath, encodedQuery, ODataProtocol.V2);
+ case HttpPost.METHOD_NAME:
+ return new ODataRequestAction(servicePath, resourcePath, null, encodedQuery, ODataProtocol.V2);
+ default:
+ throw new IllegalStateException("Unexpected HTTP request method for function import: " + httpMethod);
+ }
+ }
+
+ /**
+ * Default implementation for the case that this function returns a single type.
+ *
+ * This method can be used in subclasses to implement the {@link #executeRequest(Destination)} method.
+ *
+ * @param destination
+ * The destination to run the function against.
+ *
+ * @return The single object returned by the function call. Returns {@code null} if the function did not return a
+ * result.
+ *
+ * @throws ODataException
+ * If the execution of the function failed.
+ */
+ @SuppressWarnings( "checkstyle:IllegalCatch" )
+ @Nullable
+ protected ObjectT executeSingle( @Nonnull final Destination destination )
+ throws ODataException
+ {
+ final ODataRequestResultGeneric result = executeInternal(destination);
+ final Class extends ObjectT> resultType = getEntityClass();
+
+ if( resultType.equals(Void.class) ) {
+ return null;
+ }
+
+ final ObjectT resultObject = result.as(resultType, this::refineJsonResponse);
+
+ if( resultObject instanceof VdmEntity ) {
+ final VdmEntity> entity = (VdmEntity>) resultObject;
+
+ // use version identifier from header if present
+ result.getVersionIdentifierFromHeader().peek(entity::setVersionIdentifier);
+
+ entity.attachToService(getServicePath(), destination);
+ }
+ return resultObject;
+ }
+
+ /**
+ * Default implementation for the case that this function returns a collection of entries.
+ *
+ * This method can be used in subclasses to implement the {@link #executeRequest(Destination)} method.
+ *
+ * @param destination
+ * The destination to run the function against.
+ *
+ * @return A list of the objects returned by the function call. Returns an empty list if the function did not return
+ * a result.
+ *
+ * @throws ODataException
+ * If the execution of the function failed.
+ */
+ @Nonnull
+ @SuppressWarnings( "unchecked" )
+ protected List executeMultiple( @Nonnull final Destination destination )
+ throws ODataException
+ {
+ final ODataRequestResultGeneric result = executeInternal(destination);
+
+ final List resultObjectList = (List) result.asList(getEntityClass());
+
+ if( !resultObjectList.isEmpty() && resultObjectList.get(0) instanceof VdmEntity ) {
+ resultObjectList.forEach(entity -> ((VdmEntity>) entity).attachToService(getServicePath(), destination));
+ }
+ return resultObjectList;
+ }
+
+ @Nonnull
+ private ODataRequestResultGeneric executeInternal( final Destination destination )
+ throws ODataException
+ {
+ final ODataRequestGeneric functionImportRequest = toRequest();
+ return (ODataRequestResultGeneric) functionImportRequest
+ .execute(ApacheHttpClient5Accessor.getHttpClient(destination));
+ }
+
+ /**
+ * Transform the JSON element from the response to extract a result entity. By default this method returns the
+ * original object.
+ *
+ * @param jsonElement
+ * The optional response JSON element
+ * @return The refined JSON element
+ */
+ @Nullable
+ protected JsonElement refineJsonResponse( @Nullable final JsonElement jsonElement )
+ {
+ return jsonElement;
+ }
+
+ /**
+ * Activates CSRF token retrieval for this OData request.
+ *
+ * @return The same fluent helper that will now fetch a CSRF token.
+ * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a
+ * no-op retained only for source compatibility. It is scheduled for removal.
+ */
+ @Deprecated
+ @Nonnull
+ public FluentHelperT withCsrfToken()
+ {
+ return getThis();
+ }
+
+ /**
+ * Deactivates the CSRF token retrieval for this OData request. This is useful if the server does not support or
+ * require CSRF tokens as part of the request.
+ *
+ * @return The same builder
+ */
+ @Nonnull
+ public FluentHelperT withoutCsrfToken()
+ {
+ withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true");
+ return getThis();
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperModification.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperModification.java
new file mode 100644
index 0000000000..f580821b4e
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperModification.java
@@ -0,0 +1,46 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+
+/**
+ * Representation of an OData modification request (Create, Update, Delete) as a fluent interface for further
+ * configuring the request and {@link #executeRequest(Destination) executing} it.
+ *
+ * @param
+ * The fluent helper type.
+ * @param
+ * The type of the entity this OData request operates on, if any.
+ */
+public abstract class FluentHelperModification>
+ extends
+ FluentHelperBasic>
+{
+ /**
+ * Instantiates this fluent helper using the given service path to send the requests.
+ *
+ * @param servicePath
+ * The service path to direct the requests to.
+ * @param entityCollection
+ * The entity collection to direct the requests to.
+ */
+ public FluentHelperModification( @Nonnull final String servicePath, @Nullable final String entityCollection )
+ {
+ super(servicePath, entityCollection);
+ }
+
+ /**
+ * Deactivates the CSRF token retrieval for this OData request. This is useful if the server does not support or
+ * require CSRF tokens as part of the request.
+ *
+ * @return The same builder
+ */
+ @Nonnull
+ public FluentHelperT withoutCsrfToken()
+ {
+ withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true");
+ return getThis();
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperRead.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperRead.java
new file mode 100644
index 0000000000..3a7b394889
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperRead.java
@@ -0,0 +1,298 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCount;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Representation of an OData query as a fluent interface for further configuring the request and
+ * {@link #executeRequest(Destination) executing} it.
+ *
+ * @param
+ * The fluent helper type.
+ * @param
+ * The type of the result entity.
+ * @param
+ * The type of the {@link EntityField class that represents fields of the entity}.
+ */
+@Slf4j
+public abstract class FluentHelperRead, SelectableT>
+ extends
+ FluentHelperBasic>
+{
+
+ private final StructuredQuery delegateQuery;
+
+ /**
+ * Instantiates this fluent helper using the given service path and entity collection to send the requests.
+ *
+ * @param servicePath
+ * The service path to direct the requests to.
+ *
+ * @param entityCollection
+ * The entity collection to direct the requests to.
+ */
+ public FluentHelperRead( @Nonnull final String servicePath, @Nonnull final String entityCollection )
+ {
+ super(servicePath, entityCollection);
+
+ delegateQuery = StructuredQuery.onEntity(entityCollection, ODataProtocol.V2);
+ }
+
+ @Override
+ @Nonnull
+ public ODataRequestRead toRequest()
+ {
+ final String queryString = delegateQuery.getEncodedQueryString();
+ final ODataRequestRead request =
+ new ODataRequestRead(getServicePath(), entityCollection, queryString, ODataProtocol.V2);
+ return super.addHeadersAndCustomParameters(request);
+ }
+
+ @Override
+ @Nonnull
+ public FluentHelperT withQueryParameter( @Nonnull final String key, @Nullable final String value )
+ {
+ return super.withQueryParameter(key, value);
+ }
+
+ /**
+ * Query modifier to limit which field values of the entity get fetched and populated, and to specify which
+ * navigation properties to expand. If this method is never called, then all fields will be fetched and populated,
+ * and no navigation properties expanded. But if this method is called at least once, then only the specified fields
+ * will be fetched and populated. Calling this multiple times will combine the set(s) of fields and expansions of
+ * each call.
+ *
+ * @param fields
+ * Fields to select and/or navigation properties to expand.
+ * @return The same fluent helper with this query modifier applied.
+ */
+ @SuppressWarnings( "unchecked" )
+ @Nonnull
+ public FluentHelperT select( @Nonnull final SelectableT... fields )
+ {
+ final Iterable> selectableFields = (Iterable>) Arrays.asList(fields);
+ return super.select(selectableFields, delegateQuery::select, delegateQuery::select);
+ }
+
+ /**
+ * Query modifier to sort the set of returned entities by one or more fields. If this method is called more than
+ * once, then the result entity set will be sorted by each field in the order that the methods were called.
+ *
+ * @param field
+ * Field to sort by.
+ * @param order
+ * Sorting direction to use (ascending or descending).
+ *
+ * @return The same fluent helper with this query modifier applied.
+ */
+ @Nonnull
+ public FluentHelperT orderBy( @Nonnull final EntityField field, @Nonnull final Order order )
+ {
+ final com.sap.cloud.sdk.datamodel.odata.client.query.Order clientTypeOrder =
+ com.sap.cloud.sdk.datamodel.odata.client.query.Order.valueOf(order.toString());
+ delegateQuery.orderBy(field.getFieldName(), clientTypeOrder);
+
+ return getThis();
+ }
+
+ /**
+ * Query modifier to limit the number of entities returned. If this method is never called, then the result entity
+ * list will not be limited in size. If this method is called multiple times, then only the value of the last call
+ * will be used.
+ *
+ * @param top
+ * Number of entities to limit the result set to.
+ * @return The same fluent helper with this query modifier applied.
+ */
+ @Nonnull
+ public FluentHelperT top( @Nonnull final Number top )
+ {
+ delegateQuery.top(top);
+ return getThis();
+ }
+
+ /**
+ * Query modifier to not return the first N entities of the result set. If this method is never called, then the
+ * full list will be returned from the first entity. If this method is called multiple times, then only the value of
+ * the last call will be used.
+ *
+ * @param skip
+ * Number of entities to skip by.
+ * @return The same fluent helper with this query modifier applied.
+ */
+ @Nonnull
+ public FluentHelperT skip( @Nonnull final Number skip )
+ {
+ delegateQuery.skip(skip);
+ return getThis();
+ }
+
+ @Override
+ @Nonnull
+ public List executeRequest( @Nonnull final Destination destination )
+ throws com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException
+ {
+ final Iterable> iterablePages = iteratingPages().executeRequest(destination);
+ final Iterable iterableItems = Iterables.concat(Objects.requireNonNull(iterablePages));
+ return Lists.newArrayList(iterableItems);
+ }
+
+ /**
+ * Manually explore the individual pages from the lazy-loading entity result-set. The returning object allows for
+ * performant consumption of all data through server-driven pagination.
+ *
+ * @return An instance of {@link FluentHelperExecutable} that allows for lazy iteration through the result-set
+ * pages.
+ */
+ @Nonnull
+ public FluentHelperExecutable>> iteratingPages()
+ {
+ return this::executeInternal;
+ }
+
+ /**
+ * Manually explore the individual entities from the lazy-loading entity result-set. The returning iterable allows
+ * for performant consumption of all data through server-driven pagination.
+ *
+ * @return An instance of {@link FluentHelperExecutable} that allows for lazy iteration through the result-set
+ * pages.
+ */
+ @Nonnull
+ public FluentHelperExecutable> iteratingEntities()
+ {
+ // concat applies lazy evaluation so individual pages will still be loaded lazily
+ return destination -> Iterables.concat(executeInternal(destination));
+ }
+
+ /**
+ * Manually explore the individual entities from the lazy-loading entity result-set. The returning Stream allows for
+ * performant consumption of all data through server-driven pagination.
+ *
+ * @return An instance of {@link FluentHelperExecutable} that allows for lazy iteration through the result-set
+ * pages.
+ */
+ @Nonnull
+ public FluentHelperExecutable> streamingEntities()
+ {
+ // concat applies lazy evaluation so individual pages will still be loaded lazily
+ return destination -> Streams.stream(Iterables.concat(executeInternal(destination)));
+ }
+
+ @Nonnull
+ private Iterable> executeInternal( @Nonnull final Destination destination )
+ throws com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException
+ {
+ final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination);
+ final Iterable> result = toRequest().execute(httpClient).iteratePages(getEntityClass());
+
+ // Refine lazy iterable to attach destination properties to individual entities in the page lists.
+ //noinspection StaticPseudoFunctionalStyleMethod,ConstantConditions
+ return Iterables
+ .transform(
+ result,
+ list -> list
+ .stream()
+ .peek(entity -> entity.attachToService(getServicePath(), destination)) // enable lazy loading for navigation properties on entity
+ .collect(Collectors.toList()));
+ }
+
+ /**
+ * Query modifier to restrict the set of returned entities based on the values of one or more fields. If this method
+ * is never called, then all accessible entities will be fetched. Calling this multiple times will combine the
+ * filters into one that is the intersection of all filters (filter1 AND filter2 AND ... filterN).
+ *
+ * @param expression
+ * Fluent helper that represents a field value expression. To create this, start with an
+ * {@link com.sap.cloud.sdk.datamodel.odata.helper.EntityField EntityField} constant in your respective
+ * entity type for the desired entity field. Then call one of the comparison operators and provide a
+ * comparison value. Optionally the resulting fluent helper can be chained with other expression fluent
+ * helpers.
+ *
+ * @return The same fluent helper with this query modifier applied.
+ */
+ @Nonnull
+ public FluentHelperT filter( @Nonnull final ExpressionFluentHelper expression )
+ {
+ delegateQuery.filter(expression.getDelegateExpressionWithoutOuterParentheses());
+ return getThis();
+ }
+
+ /**
+ * Query modifier to return only the number of tuples that match the criteria specified in the query. The actual
+ * tuples are not returned. May be used with all other query modifiers. Any call to top is overloaded with top(0).
+ * Any call to skip is ignored.
+ *
+ * @return The number of tuples that match the criteria specified in the query.
+ */
+ @Nonnull
+ public FluentHelperCount count()
+ {
+ final StructuredQuery prunedQuery = StructuredQuery.onEntity(entityCollection, ODataProtocol.V2);
+
+ delegateQuery.getFilters().forEach(prunedQuery::filter);
+ delegateQuery.getCustomParameters().forEach(prunedQuery::withCustomParameter);
+
+ final ODataRequestCount requestCount =
+ new ODataRequestCount(
+ getServicePath(),
+ entityCollection,
+ prunedQuery.getEncodedQueryString(),
+ ODataProtocol.V2);
+
+ final ODataRequestCount requestCountUpdated = addHeadersAndCustomParameters(requestCount);
+
+ return new FluentHelperCount(requestCountUpdated);
+ }
+
+ /**
+ * Set the preferred page size of the OData response. A result-set may be split into multiple pages, each including
+ * a subset of the entities matching the query.
+ *
+ * Note: The OData service might ignore the preferred page size setting and may not use pagination
+ * at all.
+ *
+ * @param size
+ * The preferred page size
+ * @return This request object with the added parameter.
+ */
+ @Nonnull
+ public FluentHelperT withPreferredPageSize( final int size )
+ {
+ return withHeader("Prefer", "odata.maxpagesize=" + size);
+ }
+
+ /**
+ * Activates CSRF token retrieval for this OData request.
+ *
+ * @return The same fluent helper that will now fetch a CSRF token.
+ * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a
+ * no-op retained only for source compatibility, as read requests never require a CSRF token. It is
+ * scheduled for removal.
+ */
+ @Deprecated
+ @Nonnull
+ public FluentHelperT withCsrfToken()
+ {
+ return getThis();
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java
new file mode 100644
index 0000000000..299a47dd3e
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java
@@ -0,0 +1,293 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.google.common.annotations.Beta;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataSerializationException;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ETagSubmissionStrategy;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestUpdate;
+import com.sap.cloud.sdk.datamodel.odata.client.request.UpdateStrategy;
+
+import io.vavr.control.Option;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Representation of an OData update request as a fluent interface for further configuring the request and
+ * {@link #executeRequest(Destination) executing} it.
+ *
+ * @param
+ * The fluent helper type.
+ * @param
+ * The type of the entity to update.
+ */
+@Slf4j
+public abstract class FluentHelperUpdate>
+ extends
+ FluentHelperModification
+{
+ private final Collection> includedFields = new HashSet<>();
+ private final Collection> excludedFields = new HashSet<>();
+ private UpdateStrategy updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH;
+ private ETagSubmissionStrategy eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ETAG_FROM_ENTITY;
+
+ /**
+ * Instantiates this fluent helper using the given service path to send the requests.
+ *
+ * @param servicePath
+ * The service path to direct the requests to.
+ * @param entityCollection
+ * The entity collection to direct the requests to.
+ */
+ public FluentHelperUpdate( @Nonnull final String servicePath, @Nonnull final String entityCollection )
+ {
+ super(servicePath, entityCollection);
+ }
+
+ /**
+ * The entity object to be updated by calling the {@link #executeRequest(Destination)} method.
+ *
+ * @return The entity to be updated.
+ */
+ protected abstract EntityT getEntity();
+
+ @SuppressWarnings( "unchecked" )
+ @Nonnull
+ @Override
+ protected Class extends EntityT> getEntityClass()
+ {
+ return (Class extends EntityT>) getEntity().getClass();
+ }
+
+ /**
+ * The update request will ignore any version identifier present on the entity and not send an `If-Match` header.
+ *
+ * Warning: This might lead to a response from the remote system that the `If-Match` header is missing.
+ *
+ * It depends on the implementation of the remote system whether the `If-Match` header is expected.
+ *
+ * @return The same request builder that will not send the `If-Match` header in the update request
+ */
+ @Nonnull
+ public FluentHelperT disableVersionIdentifier()
+ {
+ eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_NO_ETAG;
+ return getThis();
+ }
+
+ /**
+ * The update request will ignore any version identifier present on the entity and update the entity, regardless of
+ * any changes on the remote entity.
+ *
+ * Warning: Be careful with this option, as this might overwrite any changes made to the remote
+ * representation of this object.
+ *
+ * @return The same request builder that will ignore the version identifier of the entity to update
+ */
+ @Nonnull
+ public FluentHelperT matchAnyVersionIdentifier()
+ {
+ eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ANY_MATCH_ETAG;
+ return getThis();
+ }
+
+ @Override
+ @Nonnull
+ public ModificationResponse executeRequest( @Nonnull final Destination destination )
+ {
+ final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination);
+
+ final ODataRequestResultGeneric result = toRequest().execute(httpClient);
+
+ return ModificationResponse.of(result, getEntity(), destination);
+ }
+
+ @Override
+ @Nonnull
+ public ODataRequestUpdate toRequest()
+ {
+ final EntityT entity = getEntity();
+ final String serializedEntity = getSerializedEntity();
+ final String versionIdentifier =
+ eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier());
+
+ final ODataRequestUpdate request =
+ new ODataRequestUpdate(
+ getServicePath(),
+ getEntityCollection(),
+ ODataEntityKey.of(entity.getKey(), ODataProtocol.V2),
+ serializedEntity,
+ updateStrategy,
+ versionIdentifier,
+ ODataProtocol.V2);
+
+ return super.addHeadersAndCustomParameters(request);
+ }
+
+ @Nonnull
+ 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:
+ return ODataEntitySerializer.serializeEntityForUpdatePut(entity, fieldsToExcludeUpdate);
+ case MODIFY_WITH_PATCH:
+ 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);
+ }
+ }
+ catch( final Exception e ) {
+ final String msg =
+ String
+ .format(
+ "Failed to serialize OData Update HTTP request entity for type %s with strategy %s",
+ getEntityClass().getSimpleName(),
+ updateStrategy);
+
+ final ODataRequestUpdate request =
+ new ODataRequestUpdate(
+ getServicePath(),
+ entity.getEntityCollection(),
+ ODataEntityKey.of(entity.getKey(), ODataProtocol.V2),
+ "",
+ updateStrategy,
+ eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier()),
+ ODataProtocol.V2);
+ throw new ODataSerializationException(request, entity, msg, e);
+ }
+ }
+
+ /**
+ * Allows to explicitly specify entity fields that shall be sent in an update request regardless if the values of
+ * these fields have been changed. This is helpful in case the API requires to send certain fields in any case in an
+ * update request.
+ *
+ * @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
+ @SafeVarargs
+ @SuppressWarnings( "varargs" )
+ public final FluentHelperT includingFields( @Nonnull final EntitySelectable... fields )
+ {
+ includedFields.addAll(Arrays.asList(fields));
+ return getThis();
+ }
+
+ /**
+ * Allows to explicitly specify entity fields that should not be sent in an update request. This is helpful in case,
+ * some services require no read only fields to be sent for update requests. These fields are only excluded in a PUT
+ * request, they are not considered in a PATCH request.
+ *
+ * @param fields
+ * The fields to be excluded in the update execution.
+ * @return The same fluent helper which will exclude the specified fields in an update request.
+ */
+ @Nonnull
+ @SafeVarargs
+ @SuppressWarnings( "varargs" )
+ public final FluentHelperT excludingFields( @Nonnull final EntitySelectable... fields )
+ {
+ excludedFields.addAll(Arrays.asList(fields));
+ return getThis();
+ }
+
+ @Nonnull
+ private String getEntityCollection()
+ {
+ return Option.of(entityCollection).getOrElse(() -> getEntity().getEntityCollection());
+ }
+
+ /**
+ * Allows to control that the request to update the entity is sent with the HTTP method PUT and its payload contains
+ * all fields of the entity, regardless which of them have been changed.
+ *
+ * @return The same fluent helper which will replace the entity in the remote system
+ */
+ @Nonnull
+ public final FluentHelperT replacingEntity()
+ {
+ updateStrategy = UpdateStrategy.REPLACE_WITH_PUT;
+ return getThis();
+ }
+
+ /**
+ * Allows to control that the request to update the entity is sent with the HTTP method PATCH and its payload
+ * contains the changed fields only.
+ *
+ * @return The same fluent helper which will modify the entity in the remote system.
+ */
+ @Nonnull
+ public final FluentHelperT modifyingEntity()
+ {
+ updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH;
+ return getThis();
+ }
+
+ /**
+ * Allows to control that the request to update the entity is sent with the HTTP method PATCH and its payload
+ * contains the changed fields only, with different strategies for handling nested fields.
+ *
+ * @param strategy
+ * The strategy to use for the PATCH update.
+ * @return The same fluent helper which will modify the entity in the remote system.
+ * @throws IllegalArgumentException
+ * If an unknown ModifyPatchStrategy is provided.
+ * @since 5.16.0
+ */
+ @Beta
+ @Nonnull
+ public final FluentHelperT modifyingEntity( @Nonnull final ModifyPatchStrategy strategy )
+ {
+ switch( strategy ) {
+ case SHALLOW:
+ return modifyingEntity();
+ case RECURSIVE_DELTA:
+ updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH_RECURSIVE_DELTA;
+ break;
+ case RECURSIVE_FULL:
+ updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH_RECURSIVE_FULL;
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown ModifyPatchStrategy: " + strategy);
+ }
+ return getThis();
+ }
+
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponse.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponse.java
new file mode 100644
index 0000000000..227c5baa8d
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponse.java
@@ -0,0 +1,154 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.google.common.annotations.Beta;
+import com.google.gson.Gson;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric;
+
+import io.vavr.control.Option;
+import io.vavr.control.Try;
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.RequiredArgsConstructor;
+import lombok.ToString;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Generic OData service response wrapper for data modification requests.
+ *
+ * @param
+ * The generic entity type.
+ */
+@EqualsAndHashCode
+@ToString
+@RequiredArgsConstructor( staticName = "of", onConstructor = @__( { @Beta } ), access = AccessLevel.PUBLIC )
+@Slf4j
+public final class ModificationResponse>
+{
+ private static final Gson GSON = new Gson();
+
+ @Nullable
+ private EntityT modifiedEntity;
+
+ @Nonnull
+ private Option responseEntity = Option.none();
+
+ @Nonnull
+ private final ODataRequestResultGeneric result;
+
+ @Nonnull
+ private final EntityT originalRequestEntity;
+
+ @Nonnull
+ private final Destination destination;
+
+ /**
+ * Get an updated version of the entity. If the service responded with an entity it is returned here. If the service
+ * didn't respond with an entity but send an @{code ETag} header, the entity is updated and returned. Otherwise a
+ * copy of the original, unmodified entity is returned.
+ *
+ * @return The modified entity.
+ */
+ @Nonnull
+ public synchronized EntityT getModifiedEntity()
+ {
+ if( modifiedEntity == null ) {
+ evaluateResponse();
+ }
+ return modifiedEntity;
+ }
+
+ /**
+ * Get the optional response entity parsed from the HTTP content.
+ *
+ * @return The parsed entity or none.
+ */
+ @Nonnull
+ public synchronized Option getResponseEntity()
+ {
+ // We synchronize the full method here because Fortify does not like double checked locking:
+ // https://vulncat.fortify.com/en/detail?id=desc.structural.java.code_correctness_double_checked_locking
+ if( modifiedEntity == null ) {
+ evaluateResponse();
+ }
+ return responseEntity;
+ }
+
+ @SuppressWarnings( "unchecked" )
+ private void evaluateResponse()
+ {
+ responseEntity = parseEntityFromResponse();
+
+ // create a copy before modifying the version identifier to not change existing objects
+ final EntityT entityToModify = responseEntity.getOrElse(originalRequestEntity);
+
+ // clone entity
+ modifiedEntity = GSON.fromJson(GSON.toJson(entityToModify), (Class) originalRequestEntity.getClass());
+
+ modifiedEntity.attachToService(result.getODataRequest().getServicePath(), destination);
+
+ modifiedEntity.setVersionIdentifier(getUpdatedVersionIdentifier().getOrNull());
+ }
+
+ /**
+ * Access the original entity used to make the request.
+ *
+ * @return The original entity object used to perform an OData request.
+ */
+ @Nonnull
+ public EntityT getRequestEntity()
+ {
+ return originalRequestEntity;
+ }
+
+ /**
+ * Get the response status code.
+ *
+ * @return The integer representation of the HTTP status code.
+ */
+ public int getResponseStatusCode()
+ {
+ return result.getHttpResponse().getCode();
+ }
+
+ /**
+ * Get the response headers.
+ *
+ * @return The headers of the HTTP status code.
+ */
+ @Nonnull
+ public Map> getResponseHeaders()
+ {
+ return result.getAllHeaderValues();
+ }
+
+ /**
+ * Get the version identifier present in the response headers, the identifier of the original entity or none, if
+ * neither exist.
+ *
+ * @return An up to date version identifier or none.
+ */
+ @Nonnull
+ public Option getUpdatedVersionIdentifier()
+ {
+ return result
+ .getVersionIdentifierFromHeader()
+ .orElse(() -> getResponseEntity().flatMap(entity -> entity.getVersionIdentifier()));
+ }
+
+ @Nonnull
+ @SuppressWarnings( "unchecked" )
+ private Option parseEntityFromResponse()
+ {
+ return Try
+ .of(() -> result.as((Class) originalRequestEntity.getClass()))
+ .onFailure(e -> log.debug("Failed to parse entity from HTTP response.", e))
+ .toOption()
+ .peek(entity -> result.getVersionIdentifierFromHeader().peek(entity::setVersionIdentifier));
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModifyPatchStrategy.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModifyPatchStrategy.java
new file mode 100644
index 0000000000..e934c50324
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModifyPatchStrategy.java
@@ -0,0 +1,23 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import com.google.common.annotations.Beta;
+
+/**
+ * Strategy to determine how a patch operation should be applied to an entity.
+ *
+ * @since 5.16.0
+ */
+@Beta
+public enum ModifyPatchStrategy
+{
+ /** Only the top level fields can be patched */
+ SHALLOW,
+
+ /** All top level and nested fields can be patched, resulting in JSON containing only the changed fields */
+ RECURSIVE_DELTA,
+
+ /**
+ * All top level and nested fields can be patched, resulting in JSON containing the full value of complex object.
+ */
+ RECURSIVE_FULL
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializer.java
new file mode 100644
index 0000000000..3898351d0d
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializer.java
@@ -0,0 +1,321 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+import java.util.stream.StreamSupport;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.sap.cloud.sdk.datamodel.odata.adapter.ODataNumberSerializer;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference;
+
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+final class ODataEntitySerializer
+{
+ private static final Gson GSON;
+ private static final Gson GSON_SERIALIZING_NULLS;
+
+ static {
+ final ODataNumberSerializer numberSerializer = new ODataNumberSerializer();
+ final GsonBuilder builder =
+ new GsonBuilder()
+ .registerTypeAdapter(Long.class, numberSerializer)
+ .registerTypeAdapter(Double.class, numberSerializer)
+ .registerTypeAdapter(Float.class, numberSerializer)
+ .registerTypeAdapter(BigDecimal.class, numberSerializer);
+ // CXSDK currently serializes Edm.SByte as plain number so the client also does it for consistency.
+ // .registerTypeAdapter(Byte.class, numberSerializer)
+
+ GSON = builder.create();
+ GSON_SERIALIZING_NULLS = builder.serializeNulls().create();
+ }
+
+ /**
+ * Serializes an entity for update request (PUT). Allowing null values. Removing potential "versionIdentifier"
+ * fields.
+ *
+ * @param entity
+ * The OData V2 entity reference.
+ * @param excludedFields
+ * Collection of fields to be excluded in the update (PUT) request.
+ * @return The serialized JSON string for entity update request.
+ */
+ @Nonnull
+ static String serializeEntityForUpdatePut(
+ @Nonnull final VdmEntity> entity,
+ @Nullable final Collection excludedFields )
+ {
+ final JsonObject jsonObject = GSON_SERIALIZING_NULLS.toJsonTree(entity).getAsJsonObject();
+
+ removeVersionIdentifier(jsonObject);
+
+ // find field names to be removed from PUT request
+ if( excludedFields != null ) {
+ excludedFields.stream().map(FieldReference::getFieldName).forEach(jsonObject::remove);
+ }
+
+ return GSON_SERIALIZING_NULLS.toJson(jsonObject);
+ }
+
+ /**
+ * Serializes an entity for create request. Ignoring empty collections and null values.
+ *
+ * @param entity
+ * The OData V2 entity reference.
+ * @return The serialized JSON string for entity create request.
+ */
+ @Nonnull
+ static String serializeEntityForCreate( @Nonnull final VdmEntity> entity )
+ {
+ // When using builder pattern, all 1:n navigation properties will be initialized with `new ArrayList()` instead of expected `null`
+ final JsonObject jsonObject = GSON.toJsonTree(entity).getAsJsonObject();
+
+ removeEmptyArrays(jsonObject);
+
+ return GSON.toJson(jsonObject);
+ }
+
+ /**
+ * Serializes an entity for update request (PATCH). Allowing null values.
+ *
+ * @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 serializeEntityForUpdatePatchShallow(
+ @Nonnull final VdmEntity> entity,
+ @Nonnull final Collection includedFields )
+ {
+ final JsonObject fullEntity = GSON_SERIALIZING_NULLS.toJsonTree(entity).getAsJsonObject();
+
+ // find field names to be patched
+ final Set fieldNamesToPatch = new HashSet<>(entity.getChangedFields().keySet());
+ includedFields.stream().map(FieldReference::getFieldName).forEach(fieldNamesToPatch::add);
+ log.debug("The following fields are marked for updates: {}.", fieldNamesToPatch);
+
+ final JsonObject partialEntity = new JsonObject();
+
+ fieldNamesToPatch.forEach(key -> partialEntity.add(key, fullEntity.get(key)));
+
+ 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);
+
+ final Predicate isNullOrString =
+ obj -> obj.isJsonNull() || obj.isJsonPrimitive() && obj.getAsJsonPrimitive().isString();
+
+ traverseJsonObject(
+ jsonObject,
+ ( element, name ) -> "versionIdentifier".equals(name) && isNullOrString.test(element),
+ JsonObject::remove);
+
+ log.debug("JSON object after removing redundant \"versionIdentifier\": {}", jsonObject);
+ }
+
+ private static void removeEmptyArrays( @Nonnull final JsonObject jsonObject )
+ {
+ log.debug("Removing empty arrays recursively from JSON object: {}", jsonObject);
+
+ traverseJsonObject(
+ jsonObject,
+ ( element, name ) -> element.isJsonArray() && element.getAsJsonArray().size() == 0,
+ JsonObject::remove);
+
+ log.debug("JSON object after removing empty arrays: {}", jsonObject);
+ }
+
+ /**
+ * Traverse the JSON object tree to apply an action on filtered items.
+ *
+ * @param jsonObject
+ * The current JSON object to check properties for.
+ * @param filter
+ * The filter as lambda (predicate) for property value and property key.
+ * @param action
+ * The action as lambda (consumer) for parent object and filtered property key.
+ */
+ private static void traverseJsonObject(
+ @Nonnull final JsonObject jsonObject,
+ @Nonnull final BiPredicate filter,
+ @Nonnull final BiConsumer action )
+ {
+ final List filteredChildKeys = new ArrayList<>();
+
+ jsonObject.entrySet().forEach(entry -> {
+ if( entry.getValue().isJsonObject() ) {
+ // Apply this logic recursively for all nested objects
+ traverseJsonObject(entry.getValue().getAsJsonObject(), filter, action);
+ } else if( filter.test(entry.getValue(), entry.getKey()) ) {
+ // Collect key names for children that tested against the provided filter predicate
+ filteredChildKeys.add(entry.getKey());
+ } else if( entry.getValue().isJsonArray() ) {
+ // Apply this logic recursively for lists of nested objects
+ final JsonArray jsonArray = entry.getValue().getAsJsonArray();
+ StreamSupport
+ .stream(jsonArray.spliterator(), false)
+ .filter(JsonElement::isJsonObject)
+ .map(JsonElement::getAsJsonObject)
+ .forEach(o -> traverseJsonObject(o, filter, action));
+ }
+ });
+
+ log
+ .trace(
+ "Applying the provided action on parent element {} for the following child items: {}",
+ jsonObject,
+ filteredChildKeys);
+
+ filteredChildKeys.forEach(affectedChildKey -> action.accept(jsonObject, affectedChildKey));
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/OneToOneLink.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/OneToOneLink.java
new file mode 100644
index 0000000000..bd5f9b329a
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/OneToOneLink.java
@@ -0,0 +1,24 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Interface designed to be implemented by {@link EntityLink} class in order to provide a filter function.
+ *
+ * @param
+ * The entity where the link starts.
+ * @param
+ * The entity where the link ends (i.e. the related entity).
+ */
+public interface OneToOneLink
+{
+ /**
+ * Add filter to a one-to-one navigation property relationship.
+ *
+ * @param filterExpression
+ * The filter expression to use for resolving the navigation property
+ * @return The fluent helper to continue constructing a filter expression.
+ */
+ @Nonnull
+ ExpressionFluentHelper filter( @Nonnull final ExpressionFluentHelper filterExpression );
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/Order.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/Order.java
new file mode 100644
index 0000000000..7ecf43a135
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/Order.java
@@ -0,0 +1,17 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+/**
+ * Used with orderBy methods in entity fluent helper objects to set the sorting order of field values.
+ */
+public enum Order
+{
+ /**
+ * Sort field values in ascending order.
+ */
+ ASC,
+
+ /**
+ * Sort field values in descending order.
+ */
+ DESC
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/SingleValuedFluentHelperFunction.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/SingleValuedFluentHelperFunction.java
new file mode 100644
index 0000000000..efa3f3c914
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/SingleValuedFluentHelperFunction.java
@@ -0,0 +1,32 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Representation of any OData function import as a fluent class for further configuring the request and
+ * {@link #executeRequest(Destination) executing} it. This is specifically for functions that return either a single
+ * primitive value or entity value
+ *
+ * @param
+ * The fluent helper type.
+ * @param
+ * The type of the object this OData request operates on, if any.
+ * @param
+ * The type of the result entity, if any.
+ */
+public abstract class SingleValuedFluentHelperFunction
+ extends
+ FluentHelperFunction
+{
+
+ /**
+ * Instantiates this fluent helper using the given service path to send the requests.
+ *
+ * @param servicePath
+ * The service path to direct the requests to.
+ */
+ public SingleValuedFluentHelperFunction( @Nonnull final String servicePath )
+ {
+ super(servicePath);
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplex.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplex.java
new file mode 100644
index 0000000000..74297e4257
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplex.java
@@ -0,0 +1,15 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import lombok.EqualsAndHashCode;
+
+/**
+ * Complex type in the virtual data model.
+ *
+ * @param
+ * Object type of the complex type.
+ */
+@EqualsAndHashCode( callSuper = true, doNotUseGetters = true )
+public abstract class VdmComplex extends VdmObject
+{
+
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntity.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntity.java
new file mode 100644
index 0000000000..837c6ba1b1
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntity.java
@@ -0,0 +1,226 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.collect.Iterables;
+import com.google.gson.annotations.SerializedName;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric;
+
+import io.vavr.control.Option;
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * Represents a {@link VdmObject} which is an entity. Entities may have a version identifier.
+ *
+ * @param
+ * The specific entity data type.
+ */
+@EqualsAndHashCode( callSuper = true, doNotUseGetters = true )
+public abstract class VdmEntity extends VdmObject
+{
+ @SerializedName( "versionIdentifier" )
+ @JsonProperty( "versionIdentifier" )
+ @Nullable
+ private String versionIdentifier = null;
+
+ /**
+ * The service path only used for the fetch commands of this entity.
+ *
+ * Note: Use with caution, as this can easily break the fetch call on this entity. See the interface of the
+ * corresponding service for the default service path.
+ */
+ @Getter( AccessLevel.PROTECTED )
+ @Setter( AccessLevel.PROTECTED )
+ @JsonIgnore
+ @EqualsAndHashCode.Exclude
+ private transient String servicePathForFetch;
+
+ /**
+ * Convenience field for reusing the same destination with multiple queries (e.g. fetching associated entities).
+ */
+ @Getter( AccessLevel.PROTECTED )
+ @Setter( AccessLevel.PROTECTED )
+ @JsonIgnore
+ @EqualsAndHashCode.Exclude
+ private transient Destination destinationForFetch;
+
+ /**
+ * Getter for the version identifier of this entity.
+ *
+ * This identifier can be used to compare this entity with a remote one. As not the whole entity has to be sent this
+ * reduces the request overhead.
+ *
+ * Actual use cases can be checking whether this entity is still current with regards to the remote entity, and
+ * ensuring that a update/delete operation is done on the expected version of the remote entity.
+ *
+ * @return The optional version identifier.
+ */
+ @Nonnull
+ public Option getVersionIdentifier()
+ {
+ return Option.of(versionIdentifier);
+ }
+
+ /**
+ * Setter for the version identifier of this entity.
+ *
+ * This identifier can be used to compare this entity with a remote one. As not the whole entity has to be sent this
+ * reduces the request overhead.
+ *
+ * Actual use cases can be checking whether this entity is still current with regards to the remote entity, and
+ * ensuring that a update/delete operation is done on the expected version of the remote entity.
+ *
+ * @param versionIdentifier
+ * The version identifier of this entity.
+ */
+ public void setVersionIdentifier( @Nullable final String versionIdentifier )
+ {
+ this.versionIdentifier = versionIdentifier;
+ }
+
+ /**
+ * Used by fluent helpers and navigation property methods to construct OData queries.
+ *
+ * @return EDMX name of the entity collection identifier.
+ */
+ @Nonnull
+ protected abstract String getEntityCollection();
+
+ /**
+ * Used by fluent helpers and navigation property methods to construct OData queries.
+ *
+ * @return Default context path to the OData service. In other words, everything in between the
+ * {@code protocol://hostname:port} and the OData resource name (entity set, {@code $metadata}, etc.)
+ */
+ @Nullable
+ protected String getDefaultServicePath()
+ {
+ return null;
+ }
+
+ /**
+ * Sets the service path and destination for the fetch commands of this entity.
+ *
+ * Also applies to any associated entities (navigation properties) that were previously fetched.
+ *
+ * Note: Use with caution, as this can easily break the fetch calls on this entity. See the interface of the
+ * corresponding service for the default service path.
+ *
+ * @param servicePath
+ * Optional parameter. New service path to apply to this entity and any associated entities that were
+ * previously fetched. If a null value is provided and the service path has never been set, then the
+ * service path will be set to the default defined in the corresponding service interface.
+ * @param destination
+ * New destination to apply to this entity and any associated entities that were previously fetched.
+ */
+ protected void attachToService( @Nullable final String servicePath, @Nonnull final Destination destination )
+ {
+ if( servicePath != null ) {
+ servicePathForFetch = servicePath;
+ } else if( servicePathForFetch == null ) {
+ servicePathForFetch = getDefaultServicePath();
+ }
+
+ destinationForFetch = destination;
+
+ toMapOfNavigationProperties().values().forEach(navProperty -> {
+ if( navProperty instanceof Iterable ) {
+ final Iterable> navPropertyList = (Iterable>) navProperty;
+ final boolean itemTypeIsEntity = Iterables.getFirst(navPropertyList, null) instanceof VdmEntity;
+
+ if( itemTypeIsEntity ) {
+ for( final Object childEntity : navPropertyList ) {
+ final VdmEntity> vdmEntity = (VdmEntity>) childEntity;
+ vdmEntity.attachToService(servicePathForFetch, destinationForFetch);
+ }
+ }
+ } else if( navProperty instanceof VdmEntity ) {
+ final VdmEntity> vdmEntity = (VdmEntity>) navProperty;
+ vdmEntity.attachToService(servicePathForFetch, destinationForFetch);
+ }
+ });
+ }
+
+ /**
+ * Helper method to lazily resolve a field value from current entity.
+ *
+ * @param fieldName
+ * The field name to lookup.
+ * @param fieldType
+ * The field type to cast the value to.
+ * @param
+ * The generic type parameter.
+ * @return A list of requested values.
+ */
+ @Nonnull
+ protected > List fetchFieldAsList(
+ @Nonnull final String fieldName,
+ @Nonnull final Class fieldType )
+ {
+ final Destination destination = getDestinationForFetch();
+ final ODataRequestResultGeneric response = fetchField(fieldName, destination);
+ final List entityList = response.asList(fieldType);
+ for( final T entity : entityList ) {
+ entity.attachToService(getServicePathForFetch(), destination);
+ }
+ return entityList;
+ }
+
+ /**
+ * Helper method to lazily resolve a field value from current entity.
+ *
+ * @param fieldName
+ * The field name to lookup.
+ * @param fieldType
+ * The field type to cast the value to.
+ * @param
+ * The generic type parameter.
+ * @return The requested values.
+ */
+ @Nonnull
+ protected > T fetchFieldAsSingle(
+ @Nonnull final String fieldName,
+ @Nonnull final Class fieldType )
+ {
+ final Destination destination = getDestinationForFetch();
+ final ODataRequestResultGeneric response = fetchField(fieldName, destination);
+ final T entity = response.as(fieldType);
+ entity.attachToService(getServicePathForFetch(), destination);
+ return entity;
+ }
+
+ @Nonnull
+ private ODataRequestResultGeneric fetchField( final String fieldName, final Destination destination )
+ {
+ final ODataEntityKey entityKey = ODataEntityKey.of(getKey(), ODataProtocol.V2);
+ final ODataResourcePath path = ODataResourcePath.of(getEntityCollection(), entityKey).addSegment(fieldName);
+ final ODataRequestRead request = new ODataRequestRead(getServicePathForFetch(), path, null, ODataProtocol.V2);
+ if( destination == null ) {
+ throw new ODataRequestException(
+ request,
+ "Failed to fetch related objects from field name "
+ + fieldName
+ + ": The entity was created locally without an assigned HttpDestination. This method is applicable only on entities which were retrieved or created using the OData VDM.",
+ null);
+ }
+ final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination);
+ return request.execute(httpClient);
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityUtil.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityUtil.java
new file mode 100644
index 0000000000..08d6c65692
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityUtil.java
@@ -0,0 +1,54 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.lang.reflect.InvocationTargetException;
+
+import javax.annotation.Nonnull;
+
+import com.sap.cloud.sdk.cloudplatform.exception.ShouldNotHappenException;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Utility class to manage OData entity deserialization.
+ *
+ * @param
+ * Entity type to create new instances from.
+ */
+@RequiredArgsConstructor
+public final class VdmEntityUtil>
+{
+ private final Class entityClass;
+
+ @Nonnull
+ EntityT newInstance()
+ {
+ try {
+ return entityClass.getDeclaredConstructor().newInstance();
+ }
+ catch( final
+ InstantiationException
+ | IllegalAccessException
+ | NoSuchMethodException
+ | InvocationTargetException e ) {
+ throw new ShouldNotHappenException(
+ "Failed to instantiate object of type " + entityClass.getSimpleName(),
+ e);
+ }
+ }
+
+ /**
+ * Helper method to resolve the expected entity type for the provided fluent helper instance. For internal use.
+ *
+ * @param fluentHelper
+ * The fluent helper instance to resolve the entity type for.
+ * @param
+ * The generic entity type.
+ * @return The entity type.
+ */
+ @SuppressWarnings( "unchecked" )
+ @Nonnull
+ public static Class getEntityClass( @Nonnull final FluentHelperBasic, ?, ?> fluentHelper )
+ {
+ return (Class) fluentHelper.getEntityClass();
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmMediaEntity.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmMediaEntity.java
new file mode 100644
index 0000000000..80d4f6cd19
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmMediaEntity.java
@@ -0,0 +1,98 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.HttpEntity;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestReadByKey;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric;
+
+import lombok.EqualsAndHashCode;
+
+/**
+ * Represents a media entity which exposes additional data under a {@code $value} endpoint.
+ *
+ * @param
+ * The specific entity type.
+ */
+@EqualsAndHashCode( callSuper = true, doNotUseGetters = true )
+public abstract class VdmMediaEntity extends VdmEntity
+{
+ /**
+ * Get the binary data stream (file) from this media entity. Perform this operation after retrieving the
+ * entity object from the OData service.
+ *
+ * Alternatively, you can use this method to only retrieve the media resource without requesting the entity
+ * data. Build this entity via its {@code .builder()} and use {@link #attachToService(String, Destination)} to
+ * declare a service path and destination to request the media resource from. You can obtain the service path from
+ * the {@code #DEFAULT_SERVICE_PATH}, e.g.
+ * {@code BusinessPartnerServiceBusinessPartnerService.DEFAULT_SERVICE_PATH}
+ *
+ * Please ensure this stream is closed after usage. The below example achieves this using
+ * try-with-resources:
+ *
+ *
+ * try( InputStream content = entity.fetchMediaStream() ) {
+ * // do something with the content here
+ * }
+ *
+ *
+ * @return File content as an {@link InputStream}.
+ * @throws ODataException
+ * if the request could not be sent or the OData service responded with an error.
+ */
+ @Nonnull
+ public InputStream fetchMediaStream()
+ throws ODataException
+ {
+ final ODataResourcePath resource =
+ ODataResourcePath
+ .of(getEntityCollection(), ODataEntityKey.of(getKey(), ODataProtocol.V2))
+ .addSegment("$value");
+
+ final ODataRequestReadByKey request =
+ new ODataRequestReadByKey(getServicePathForFetch(), resource, null, ODataProtocol.V2);
+
+ final Destination destination = getDestinationForFetch();
+ if( destination == null ) {
+ throw new ODataRequestException(
+ request,
+ "Failed to fetch media stream.",
+ new IllegalStateException(
+ "Unable to execute OData query. The entity was created locally without an assigned HttpDestination. This method is applicable only on entities which were retrieved or created using the OData VDM."));
+ }
+ final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination);
+ final ODataRequestResultGeneric result = request.execute(httpClient);
+ @SuppressWarnings( "PMD.CloseResource" ) // entity is intentionally not closed here because its content stream is returned to the caller
+ final HttpEntity entity = result.getHttpResponse().getEntity();
+ if( entity == null ) {
+ throw new ODataResponseException(
+ request,
+ result.getHttpResponse(),
+ "Failed to read the input stream of the OData response: Response didn't contain any payload.",
+ null);
+ }
+ try {
+ return entity.getContent();
+ }
+ catch( final IOException | UnsupportedOperationException e ) {
+ throw new ODataResponseException(
+ request,
+ result.getHttpResponse(),
+ "Failed to read the input stream of the OData response.",
+ e);
+ }
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmObject.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmObject.java
new file mode 100644
index 0000000000..48549f0e40
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmObject.java
@@ -0,0 +1,373 @@
+package com.sap.cloud.sdk.datamodel.odata.helper;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.sap.cloud.sdk.s4hana.datamodel.odata.exception.NoSuchEntityFieldException;
+import com.sap.cloud.sdk.typeconverter.TypeConverter;
+
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+/**
+ * Superclass of all entities which contains common elements such as a generic representation of custom fields.
+ *
+ * @param
+ * The type of the implementing object.
+ */
+@ToString( doNotUseGetters = true )
+@EqualsAndHashCode( doNotUseGetters = true )
+@JsonAutoDetect(
+ fieldVisibility = JsonAutoDetect.Visibility.ANY,
+ getterVisibility = JsonAutoDetect.Visibility.NONE,
+ isGetterVisibility = JsonAutoDetect.Visibility.NONE,
+ setterVisibility = JsonAutoDetect.Visibility.NONE,
+ creatorVisibility = JsonAutoDetect.Visibility.NONE )
+public abstract class VdmObject
+{
+ @JsonIgnore
+ private final transient Map customFields = new LinkedHashMap<>();
+
+ /**
+ * A mapping of the OData field name to the original value.
+ *
+ * This should be updated via {@link #rememberChangedField(String, Object)} on every set call of a property.
+ */
+ @JsonIgnore
+ @Nonnull
+ protected final transient Map changedOriginalFields = new HashMap<>();
+
+ /**
+ * Returns the names of all custom fields of this object.
+ *
+ * @return The names of the custom fields of this object.
+ */
+ @Nonnull
+ public Set getCustomFieldNames()
+ {
+ return customFields.keySet();
+ }
+
+ /**
+ * Returns all custom field names and values of this object.
+ *
+ * @return All names & values of custom fields as a map.
+ */
+ @JsonAnyGetter
+ @Nonnull
+ public Map getCustomFields()
+ {
+ return customFields;
+ }
+
+ /**
+ * Sets the value of a single custom field.
+ *
+ * @param customFieldName
+ * Name of the custom field.
+ * @param value
+ * Value of the custom field.
+ */
+ @JsonAnySetter
+ public void setCustomField( @Nonnull final String customFieldName, @Nullable final Object value )
+ {
+ rememberChangedField(customFieldName, customFields.get(customFieldName));
+ customFields.put(customFieldName, value);
+ }
+
+ /**
+ * Sets the value of a single custom field. If the EntityField passed as parameter holds a TypeConverter, the value
+ * will be converted before it's stored.
+ *
+ * @param customField
+ * Name of the custom field, represented as an EntityField object.
+ * @param value
+ * Value of the custom field.
+ * @param
+ * The type of the custom field to set.
+ */
+ public void setCustomField(
+ @Nonnull final EntityField customField,
+ @Nullable final FieldT value )
+ {
+ if( customField.getTypeConverter() != null ) {
+ setCustomField(customField.getFieldName(), customField.getTypeConverter().toDomain(value).get());
+ } else {
+ setCustomField(customField.getFieldName(), value);
+ }
+ }
+
+ /**
+ * Checks whether this object contains a custom field with the given name.
+ *
+ * @param customFieldName
+ * Name of the custom field to check for
+ *
+ * @return {@code true} if this entity has a custom field with the given name, {@code false} otherwise.
+ */
+ public boolean hasCustomField( @Nonnull final String customFieldName )
+ {
+ return customFields.containsKey(customFieldName);
+ }
+
+ /**
+ * Checks whether this object contains a value for the given custom field.
+ *
+ * @param customField
+ * Custom field to check for, represented as an {@code EntityField} object.
+ *
+ * @return {@code true} if this object has a custom field with the name of the given field, {@code false} otherwise.
+ */
+ public boolean hasCustomField( @Nonnull final EntityField customField )
+ {
+ return hasCustomField(customField.getFieldName());
+ }
+
+ /**
+ * This method allows for retrieval of custom fields that are added to the underlying OData services.
+ *
+ * @param customFieldName
+ * Name of the field returned by the underlying OData service.
+ * @param
+ * The type of the returned field.
+ *
+ * @return The value of the custom field. Actual type will depend on the type configured in the underlying OData
+ * service.
+ *
+ * @throws NoSuchEntityFieldException
+ * if no field with the given name could be found.
+ */
+ @SuppressWarnings( "unchecked" )
+ @Nullable
+ public FieldT getCustomField( @Nonnull final String customFieldName )
+ throws NoSuchEntityFieldException
+ {
+ if( !hasCustomField(customFieldName) ) {
+ throw new NoSuchEntityFieldException("Object has no field with name '" + customFieldName + "'.");
+ }
+ return (FieldT) customFields.get(customFieldName);
+ }
+
+ @SuppressWarnings( "unchecked" )
+ @Nullable
+ private FieldT getCustomField(
+ @Nonnull final String customFieldName,
+ @Nonnull final TypeConverter typeConverter )
+ throws NoSuchEntityFieldException
+ {
+ if( !hasCustomField(customFieldName) ) {
+ throw new NoSuchEntityFieldException("Object has no field with name '" + customFieldName + "'.");
+ }
+ return typeConverter.fromDomain((T) customFields.get(customFieldName)).get();
+ }
+
+ /**
+ * This method allows for retrieval of custom fields that are added to the underlying OData services. If the
+ * EntityField passed as parameter holds a TypeConverter, the value will be converted before it's returned.
+ *
+ * @param customField
+ * Field returned by the underlying OData service.
+ * @param
+ * The type of the returned field.
+ *
+ * @return The value of the custom field. Actual type will depend on the type configured in the underlying OData
+ * service.
+ *
+ * @throws NoSuchEntityFieldException
+ * if no field with the given name could be found.
+ */
+ @Nullable
+ public FieldT getCustomField( @Nonnull final EntityField customField )
+ throws NoSuchEntityFieldException
+ {
+ if( customField.getTypeConverter() != null ) {
+ return getCustomField(customField.getFieldName(), customField.getTypeConverter());
+ } else {
+ return getCustomField(customField.getFieldName());
+ }
+ }
+
+ /**
+ * Returns the class of this object.
+ *
+ * @return The class of this object.
+ */
+ @Nonnull
+ public abstract Class getType();
+
+ /**
+ * Returns the compound key of this object.
+ *
+ * @return The compound key of this object.
+ */
+ @Nonnull
+ protected Map getKey()
+ {
+ return new HashMap<>();
+ }
+
+ /**
+ * Sets the values of all custom fields contained in the given {@code values}.
+ *
+ * Afterwards, marks all fields as unchanged.
+ *
+ *
+ * @param values
+ * The map of custom fields to set.
+ */
+ protected void fromMap( final Map values )
+ {
+ for( final Map.Entry entry : values.entrySet() ) {
+ setCustomField(entry.getKey(), entry.getValue());
+ }
+
+ resetChangedFields();
+ }
+
+ /**
+ * Returns a map of all custom fields contained in this object.
+ *
+ * @return A map of all custom fields contained in this object.
+ */
+ @Nonnull
+ protected Map toMapOfCustomFields()
+ {
+ return Maps.newHashMap(getCustomFields());
+ }
+
+ /**
+ * Returns a set of all custom field names contained in this object.
+ *
+ * @return A set of all custom field names contained in this object.
+ */
+ @Nonnull
+ protected Set getSetOfCustomFields()
+ {
+ return Sets.newHashSet(getCustomFields().keySet());
+ }
+
+ /**
+ * Returns a map of all fields contained in this object.
+ *
+ * @return A map of all fields contained in this object.
+ */
+ @Nonnull
+ protected Map toMapOfFields()
+ {
+ return new HashMap<>();
+ }
+
+ /**
+ * Returns a set of all field names contained in this object.
+ *
+ * @return A set of all field names contained in this object.
+ */
+ @Nonnull
+ protected Set getSetOfFields()
+ {
+ return Sets.newHashSet(toMapOfFields().keySet());
+ }
+
+ /**
+ * Returns a map of all navigation properties contained in this object.
+ *
+ * @return A map of all navigation properties contained in this object.
+ */
+ @Nonnull
+ protected Map toMapOfNavigationProperties()
+ {
+ return new HashMap<>();
+ }
+
+ /**
+ * Returns a set of all navigation property names contained in this object.
+ *
+ * @return A set of all navigation property names contained in this object.
+ */
+ @Nonnull
+ protected Set getSetOfNavigationProperties()
+ {
+ return Sets.newHashSet(toMapOfNavigationProperties().keySet());
+ }
+
+ /**
+ * Returns a map of all fields, navigation properties, and custom fields contained in this object.
+ *
+ * @return A map of all fields, navigation properties, and custom fields contained in this object.
+ */
+ @Nonnull
+ protected Map toMap()
+ {
+ final Map values = new HashMap<>();
+
+ values.putAll(toMapOfFields());
+ values.putAll(toMapOfNavigationProperties());
+ values.putAll(toMapOfCustomFields());
+
+ return values;
+ }
+
+ /**
+ * Returns map of all fields which have been changed on this entity along with their updated values.
+ *
+ * @return Map containing all changed fields with their current value.
+ */
+ @Nonnull
+ public Map getChangedFields()
+ {
+ final Map changedFields = new HashMap<>();
+
+ final Map currentFields = toMapOfFields();
+ currentFields.putAll(getCustomFields());
+
+ for( final Map.Entry changedOriginalField : changedOriginalFields.entrySet() ) {
+ final Object originalValue = changedOriginalField.getValue();
+ final Object currentValue = currentFields.get(changedOriginalField.getKey());
+
+ if( originalValue != null && !originalValue.equals(currentValue)
+ || originalValue == null && currentValue != null ) {
+
+ changedFields.put(changedOriginalField.getKey(), currentValue);
+ }
+ }
+
+ return changedFields;
+ }
+
+ /**
+ * Remembers the original value of a changed field.
+ *
+ * @param fieldName
+ * The name of the field that is changed.
+ * @param valueBeforeChange
+ * The original value before the change.
+ */
+ protected void rememberChangedField( @Nonnull final String fieldName, @Nullable final Object valueBeforeChange )
+ {
+ if( !changedOriginalFields.containsKey(fieldName) ) {
+ changedOriginalFields.put(fieldName, valueBeforeChange);
+ }
+ }
+
+ /**
+ * Resets the map of all fields which have been changed on this entity.
+ *
+ * After calling this method, no field is considered changed, until you change the value of fields on this entity
+ * afterwards.
+ */
+ public void resetChangedFields()
+ {
+ changedOriginalFields.clear();
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchChangeSetFluentHelperBasic.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchChangeSetFluentHelperBasic.java
new file mode 100644
index 0000000000..d19638cc57
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchChangeSetFluentHelperBasic.java
@@ -0,0 +1,216 @@
+package com.sap.cloud.sdk.datamodel.odata.helper.batch;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestAction;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestDelete;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestUpdate;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperCreate;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperDelete;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperFunction;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperUpdate;
+import com.sap.cloud.sdk.datamodel.odata.helper.VdmEntity;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Representation of any changeset in a OData batch request as a fluent interface.
+ *
+ * @param
+ * The fluent helper type of the associated OData batch request.
+ * @param
+ * The current type of the implementing fluent helper instance.
+ */
+@RequiredArgsConstructor
+public abstract class BatchChangeSetFluentHelperBasic, ThisT>
+ implements
+ FluentHelperBatchEndChangeSet
+{
+ private final FluentHelperBatchT typedBatchFluentHelper;
+ private final BatchFluentHelperBasic, ?> basicBatchFluentHelper;
+
+ private final List operations = new ArrayList<>();
+
+ /**
+ * Method to safely return the current fluent helper instance upon public method calls.
+ *
+ * @return The current fluent helper instance.
+ */
+ @Nonnull
+ protected abstract ThisT getThis();
+
+ /**
+ * Add a delete operation to the current changeset request.
+ *
+ * @param serviceMethod
+ * The method of a delete fluent helper from the given service class.
+ * @param entity
+ * The entity instance to be deleted.
+ * @param
+ * The entity type to guarantee type safety.
+ * @return The current fluent helper instance.
+ */
+ @Nonnull
+ protected > ThisT addRequestDelete(
+ final Function> serviceMethod,
+ final EntityT entity )
+ {
+ final FluentHelperDelete, EntityT> delete = serviceMethod.apply(entity);
+ return addRequest(delete);
+ }
+
+ /**
+ * Adds a delete operation to the current changeset request.
+ *
+ * @param deleteRequest
+ * The {@link FluentHelperDelete} that represents the delete operation.
+ * @return The current fluent helper instance.
+ */
+ @Nonnull
+ protected ThisT addRequest( @Nonnull final FluentHelperDelete, ?> deleteRequest )
+ {
+ final ODataRequestDelete request = deleteRequest.toRequest();
+ basicBatchFluentHelper.requestMapping.put(deleteRequest, request);
+
+ final BatchRequestChangeSetOperation operation =
+ new BatchRequestChangeSetOperation(( changeSet ) -> changeSet.addDelete(request), deleteRequest);
+
+ operations.add(operation);
+ return getThis();
+ }
+
+ /**
+ * Add a create operation to the current changeset request.
+ *
+ * @param serviceMethod
+ * The method of a create fluent helper from the given service class.
+ * @param entity
+ * The entity instance to be created.
+ * @param
+ * The entity type to guarantee type safety.
+ * @return The current fluent helper instance.
+ */
+ @Nonnull
+ protected > ThisT addRequestCreate(
+ final Function> serviceMethod,
+ final EntityT entity )
+ {
+ final FluentHelperCreate, EntityT> create = serviceMethod.apply(entity);
+ return addRequest(create);
+ }
+
+ /**
+ * Adds a create operation to the current changeset request.
+ *
+ * @param createRequest
+ * The {@link FluentHelperCreate} that represents the create operation.
+ * @return The current fluent helper instance.
+ */
+ @Nonnull
+ protected ThisT addRequest( @Nonnull final FluentHelperCreate, ?> createRequest )
+ {
+ final ODataRequestCreate request = createRequest.toRequest();
+ basicBatchFluentHelper.requestMapping.put(createRequest, request);
+
+ final BatchRequestChangeSetOperation operation =
+ new BatchRequestChangeSetOperation(( changeSet ) -> changeSet.addCreate(request), createRequest);
+
+ operations.add(operation);
+ return getThis();
+ }
+
+ /**
+ * Add an update operation to the current changeset request.
+ *
+ * @param serviceMethod
+ * The method of an update fluent helper from the given service class.
+ * @param entity
+ * The entity instance to be updated.
+ * @param
+ * The entity type to guarantee type safety.
+ * @return The current fluent helper instance.
+ */
+ @Nonnull
+ protected > ThisT addRequestUpdate(
+ final Function> serviceMethod,
+ final EntityT entity )
+ {
+ final FluentHelperUpdate, EntityT> update = serviceMethod.apply(entity);
+ return addRequest(update);
+ }
+
+ /**
+ * Adds an update operation to the current changeset request.
+ *
+ * @param updateRequest
+ * The {@link FluentHelperUpdate} that represents the update operation.
+ * @return The current fluent helper instance.
+ */
+ @Nonnull
+ protected ThisT addRequest( @Nonnull final FluentHelperUpdate, ?> updateRequest )
+ {
+ final ODataRequestUpdate request = updateRequest.toRequest();
+ basicBatchFluentHelper.requestMapping.put(updateRequest, request);
+
+ final BatchRequestChangeSetOperation operation =
+ new BatchRequestChangeSetOperation(( changeSet ) -> changeSet.addUpdate(request), updateRequest);
+
+ operations.add(operation);
+ return getThis();
+ }
+
+ @Nonnull
+ @Override
+ public FluentHelperBatchT endChangeSet()
+ {
+ final BatchRequestChangeSet changeSet = new BatchRequestChangeSet(operations);
+ basicBatchFluentHelper.addChangeSet(changeSet);
+ return typedBatchFluentHelper;
+ }
+
+ /**
+ * Adds a function import call to the currently opened changeset. Only use {@code executeRequest} to issue the batch
+ * request.
+ *
+ * @param functionImport
+ * The {@link FluentHelperFunction} that represents the function import call
+ * @return The same fluent helper
+ * @throws IllegalStateException
+ * If the batch request contains a function import call within a change set which uses the HTTP GET
+ * method.
+ */
+ @Nonnull
+ public ThisT addFunctionImport( @Nonnull final FluentHelperFunction, ?, ?> functionImport )
+ {
+ final ODataRequestGeneric request = functionImport.toRequest();
+ basicBatchFluentHelper.requestMapping.put(functionImport, request);
+
+ final Consumer changesetConsumer;
+
+ if( request instanceof ODataRequestAction ) {
+ changesetConsumer = changeset -> changeset.addAction((ODataRequestAction) request);
+ } else {
+ throw new IllegalStateException(
+ "Request for function imports in batch change sets must be "
+ + ODataRequestAction.class.getSimpleName()
+ + ", but was "
+ + request.getClass().getSimpleName()
+ + ". Only function imports using HTTP POST are allowed.");
+ }
+
+ final BatchRequestChangeSetOperation operation =
+ new BatchRequestChangeSetOperation(changesetConsumer, functionImport);
+
+ operations.add(operation);
+
+ return getThis();
+ }
+}
diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchFluentHelperBasic.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchFluentHelperBasic.java
new file mode 100644
index 0000000000..a14fd06829
--- /dev/null
+++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchFluentHelperBasic.java
@@ -0,0 +1,219 @@
+package com.sap.cloud.sdk.datamodel.odata.helper.batch;
+
+import java.util.ArrayList;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Supplier;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestAction;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestFunction;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestReadByKey;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultMultipartGeneric;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperBasic;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperByKey;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperCreate;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperDelete;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperFunction;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperModification;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperRead;
+import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperUpdate;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+
+/**
+ * Representation of any OData batch request as a fluent interface for managing changesets and
+ * {@link #executeRequest(Destination) executing} them in a single query.
+ *
+ * @param
+ * The fluent helper type.
+ * @param
+ * The type of the changesets being managed in this OData batch request.
+ */
+public abstract class BatchFluentHelperBasic, FluentHelperBatchChangeSetT extends FluentHelperBatchEndChangeSet>
+ implements
+ FluentHelperServiceBatch
+{
+ @Getter( AccessLevel.PACKAGE )
+ private final List requestParts = new ArrayList<>();
+ final Map, Integer> requestMappingLegacy = new IdentityHashMap<>();
+ @Getter( AccessLevel.PACKAGE )
+ final Map