From fa96dccaa5917be2b00e8e72215490c0acc0c7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Tue, 26 May 2026 11:56:50 +0200 Subject: [PATCH 1/3] Initial --- .../datamodel/odata/client/ODataProtocol.java | 24 +++++++++++++++++++ .../odata/client/query/QuerySerializer.java | 9 +++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataProtocol.java b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataProtocol.java index f540596ec9..44c3927c36 100644 --- a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataProtocol.java +++ b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataProtocol.java @@ -11,8 +11,10 @@ import java.time.temporal.ChronoField; import java.util.AbstractMap; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.function.Function; +import java.util.function.Predicate; import javax.annotation.Nonnull; @@ -52,6 +54,20 @@ public interface ODataProtocol extends ODataResponseDescriptor, ODataLiteralSeri @Nonnull Map.Entry getQueryOptionInlineCount( boolean optionEnabled ); + /** + * Check whether custom query parameter can be attached to a structured-query. + * + * @param isRoot + * indicates whether the structured-query is the root query or a nested query. + * @param key + * the key of the custom query parameter. + * @return true if the custom query parameter is allowed, false otherwise. + */ + default boolean allowCustomQueryParameter( final boolean isRoot, @Nonnull final String key ) + { + return isRoot; + } + /** * OData protocol v2. */ @@ -190,6 +206,14 @@ public String toString() { return "OData " + protocolVersion; } + + @Override + public boolean allowCustomQueryParameter( final boolean isRoot, @Nonnull final String key ) + { + final Predicate expandOption = + Set.of("$count", "$filter", "$orderby", "$search", "$skip", "$top")::contains; + return isRoot || expandOption.test(key); + } } /** diff --git a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java index 3784ed1475..a2534e64ce 100644 --- a/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java +++ b/datamodel/odata-client/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java @@ -49,10 +49,11 @@ static String serializeAndEncodeQuery( @Nonnull final StructuredQuery query, fin .map(q -> String.format(parameterString, q)) .forEach(parameters::add)); - if( query.isRoot() ) { - query - .getCustomParameters() - .forEach(( key, value ) -> parameters.add(key + "=" + conditionalEncode(value, applyEncoding))); + for( final Map.Entry customParam : query.getCustomParameters().entrySet() ) { + final String key = customParam.getKey(); + if( query.getProtocol().allowCustomQueryParameter(query.isRoot(), key) ) { + parameters.add(key + "=" + conditionalEncode(customParam.getValue(), applyEncoding)); + } } final String queryElementSeparator = query.isRoot() ? SEPARATOR_ROOT_QUERY : SEPARATOR_SUB_QUERY; From e3dd4eec69cc4f98db76697e6ed8caa6be5f49ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Tue, 26 May 2026 11:56:59 +0200 Subject: [PATCH 2/3] Add test --- .../client/request/ODataRequestReadTest.java | 94 ++++++++++++------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/datamodel/odata-client/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadTest.java b/datamodel/odata-client/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadTest.java index 2b1cd903c2..833afd985b 100644 --- a/datamodel/odata-client/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadTest.java +++ b/datamodel/odata-client/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadTest.java @@ -6,6 +6,8 @@ import static com.github.tomakehurst.wiremock.client.WireMock.okJson; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V2; +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V4; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -23,7 +25,6 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpClientAccessor; -import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression; @@ -63,7 +64,7 @@ void testQueryParameters() final String queryString = "$select=select1&$expand=expand1,expand2($select=nestedSelect;$top=10)&$top=1"; final ODataRequestRead request = - new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, queryString, ODataProtocol.V4); + new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, queryString, V4); request.addQueryParameter("query-param1", "qp1"); request.addQueryParameter("query-param2", "qp2"); request.addHeader("header-key1", "hk1"); @@ -91,22 +92,37 @@ void testQueryParameters() @Test void testV4QueryExpand() { - final StructuredQuery query = StructuredQuery.onEntity("Movies", ODataProtocol.V4); - final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V4); - final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", ODataProtocol.V4); - final StructuredQuery subQuery3 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V4); + final StructuredQuery query = StructuredQuery.onEntity("Movies", V4); + final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V4); + final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", V4); + final StructuredQuery subQuery3 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V4); subQuery2.select(subQuery3); subQuery1.select(subQuery2); query.select(subQuery1); assertThat(query.getQueryString()).isEqualTo("$expand=relatedBook($expand=relatedMovies($expand=relatedBook))"); } + @Test + void testV4QueryExpandWithOptions() + { + final StructuredQuery query = StructuredQuery.onEntity("Movies", V4), + subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V4).skip(3).top(10), + subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", V4).orderBy("Duration", Order.DESC), + subQuery3 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V4).withInlineCount(); + subQuery2.select(subQuery3); + subQuery1.select(subQuery2); + query.select(subQuery1); + assertThat(query.getQueryString()) + .isEqualTo( + "$expand=relatedBook($expand=relatedMovies($expand=relatedBook($count=true);$orderby=Duration desc);$top=10;$skip=3)"); + } + @Test void testV4QuerySelectAndFilter() { - final StructuredQuery query = StructuredQuery.onEntity("Movies", ODataProtocol.V4); - final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V4); - final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", ODataProtocol.V4); + final StructuredQuery query = StructuredQuery.onEntity("Movies", V4); + final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V4); + final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", V4); subQuery1.select(subQuery2); query.select(subQuery1); @@ -128,10 +144,10 @@ void testV4QuerySelectAndFilter() @Test void testV2QueryExpand() { - final StructuredQuery query = StructuredQuery.onEntity("Movies", ODataProtocol.V2); - final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V2); - final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", ODataProtocol.V2); - final StructuredQuery subQuery3 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V2); + final StructuredQuery query = StructuredQuery.onEntity("Movies", V2); + final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V2); + final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", V2); + final StructuredQuery subQuery3 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V2); subQuery2.select(subQuery3); subQuery1.select(subQuery2); query.select(subQuery1); @@ -143,12 +159,26 @@ void testV2QueryExpand() */ } + @Test + void testV2QueryExpandWithOptionsIgnored() + { + final StructuredQuery query = StructuredQuery.onEntity("Movies", V2), + subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V2).skip(3).top(10), + subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", V2).orderBy("Duration", Order.DESC), + subQuery3 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V2).withInlineCount(); + subQuery2.select(subQuery3); + subQuery1.select(subQuery2); + query.select(subQuery1); + assertThat(query.getQueryString()) + .isEqualTo("$expand=relatedBook,relatedBook/relatedMovies,relatedBook/relatedMovies/relatedBook"); + } + @Test void testV2QuerySelectAndFilter() { - final StructuredQuery query = StructuredQuery.onEntity("Movies", ODataProtocol.V2); - final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V2); - final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", ODataProtocol.V2); + final StructuredQuery query = StructuredQuery.onEntity("Movies", V2); + final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", V2); + final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", V2); subQuery1.select(subQuery2); query.select(subQuery1); @@ -178,8 +208,7 @@ void testExceptionWhenUnencodedQueryString() final String unencodedQuery = "$orderby=name asc,ID"; assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy( - () -> new ODataRequestRead(servicePath, entityName, unencodedQuery, ODataProtocol.V4).getRelativeUri()); + .isThrownBy(() -> new ODataRequestRead(servicePath, entityName, unencodedQuery, V4).getRelativeUri()); } @Test @@ -192,8 +221,7 @@ void testGuavaUrlEscaperEscapedQueryString() final String encodedQuery = UrlEscapers.urlFragmentEscaper().escape(unencodedQuery); - final URI relativeUri = - new ODataRequestRead(servicePath, entityName, encodedQuery, ODataProtocol.V4).getRelativeUri(); + final URI relativeUri = new ODataRequestRead(servicePath, entityName, encodedQuery, V4).getRelativeUri(); final String expectedUri = "/odata/v4/Service/Authors?$orderby=name%20asc,ID"; @@ -208,15 +236,14 @@ void testBuildQueryStringWithStructuredQueryV2() final StructuredQuery structuredQuery = StructuredQuery - .onEntity(entityName, ODataProtocol.V2) + .onEntity(entityName, V2) .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) .withInlineCount(); final String encodedQuery = structuredQuery.getEncodedQueryString(); - final URI relativeUri = - new ODataRequestRead(servicePath, entityName, encodedQuery, ODataProtocol.V2).getRelativeUri(); + final URI relativeUri = new ODataRequestRead(servicePath, entityName, encodedQuery, V2).getRelativeUri(); final String expectedUri = "/odata/v2/Service/Authors?$filter=(philosphy%20eq%20'Yin%20%26%20Yang')&$orderby=name%20asc,ID%20asc&$inlinecount=allpages"; @@ -232,15 +259,14 @@ void testBuildQueryStringWithStructuredQueryV4() final StructuredQuery structuredQuery = StructuredQuery - .onEntity(entityName, ODataProtocol.V4) + .onEntity(entityName, V4) .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) .withInlineCount(); final String encodedQuery = structuredQuery.getEncodedQueryString(); - final URI relativeUri = - new ODataRequestRead(servicePath, entityName, encodedQuery, ODataProtocol.V4).getRelativeUri(); + final URI relativeUri = new ODataRequestRead(servicePath, entityName, encodedQuery, V4).getRelativeUri(); final String expectedUri = "/odata/v4/Service/Authors?$filter=(philosphy%20eq%20'Yin%20%26%20Yang')&$orderby=name%20asc,ID%20asc&$count=true"; @@ -257,7 +283,7 @@ void testCustomParameterswithStructuredQuery() final StructuredQuery structuredQuery = StructuredQuery - .onEntity(entityName, ODataProtocol.V4) + .onEntity(entityName, V4) .filter(FieldReference.of("philosophy").equalTo("Yin & Yang")) .withCustomParameter(customKey, customValue); @@ -270,7 +296,7 @@ void testCustomParameterswithStructuredQuery() @Test void testCustomParametersOnNestedQuery() { - final StructuredQuery query = StructuredQuery.asNestedQueryOnProperty("name", ODataProtocol.V4); + final StructuredQuery query = StructuredQuery.asNestedQueryOnProperty("name", V4); assertThatThrownBy(() -> query.withCustomParameter("key", "value")).isInstanceOf(IllegalStateException.class); } @@ -278,7 +304,7 @@ void testCustomParametersOnNestedQuery() @Test void testCustomParametersWithEmptyKey() { - final StructuredQuery query = StructuredQuery.onEntity("name", ODataProtocol.V4); + final StructuredQuery query = StructuredQuery.onEntity("name", V4); assertThatThrownBy(() -> query.withCustomParameter("", "value")).isInstanceOf(IllegalArgumentException.class); } @@ -288,17 +314,13 @@ void testConstructorWithStructuredQuery() { final StructuredQuery structuredQuery = StructuredQuery - .onEntity("Authors", ODataProtocol.V4) + .onEntity("Authors", V4) .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) .withInlineCount(); final ODataRequestRead expected = - new ODataRequestRead( - "/some/service/path", - "Authors", - structuredQuery.getEncodedQueryString(), - ODataProtocol.V4); + new ODataRequestRead("/some/service/path", "Authors", structuredQuery.getEncodedQueryString(), V4); final ODataRequestRead actual = new ODataRequestRead("/some/service/path", new ODataResourcePath(), structuredQuery); @@ -310,7 +332,7 @@ void testConstructorWithStructuredQuery() @Test void testConstructorWithStructuredQueryDoesNotMutateResourcePath() { - final StructuredQuery structuredQuery = StructuredQuery.onEntity("Authors", ODataProtocol.V4).withInlineCount(); + final StructuredQuery structuredQuery = StructuredQuery.onEntity("Authors", V4).withInlineCount(); final ODataResourcePath resourcePath = ODataResourcePath.of("Authors"); new ODataRequestRead("/some/service/path", resourcePath, structuredQuery); From 0b770aa29069ec12f15fbf7ced19f1472d01fda2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Tue, 26 May 2026 11:58:26 +0200 Subject: [PATCH 3/3] Add release note --- release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release_notes.md b/release_notes.md index 3112514a9d..68baca2c54 100644 --- a/release_notes.md +++ b/release_notes.md @@ -16,7 +16,7 @@ ### 📈 Improvements -- +- (Generic OData Client) Allow for parameters in OData v4 expand sub-queries. ### 🐛 Fixed Issues