Skip to content

Commit

Permalink
feat: add $expand support for entity collections
Browse files Browse the repository at this point in the history
This is a limited implementation of the $expand option. It has the
following limitations:
- Only work for read requests
- Only work for attributes of the requested entity
- No support for filter options based on attributes of expanded entities

[1] http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html#_Toc453752359
  • Loading branch information
pk-work authored and SAPDaniloWork committed Mar 10, 2021
1 parent d299658 commit 92dbcde
Show file tree
Hide file tree
Showing 11 changed files with 566 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/.commitlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
0,
"always",
100
],
"footer-max-line-length": [
0,
"always",
100
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.util.Locale;

import org.apache.olingo.commons.api.data.ContextURL;
import org.apache.olingo.commons.api.data.ContextURL.Suffix;
import org.apache.olingo.commons.api.data.Entity;
import org.apache.olingo.commons.api.data.EntityCollection;
import org.apache.olingo.commons.api.edm.EdmEntitySet;
Expand Down Expand Up @@ -37,9 +38,11 @@
import io.neonbee.data.DataRequest;
import io.neonbee.data.internal.DataContextImpl;
import io.neonbee.entity.EntityWrapper;
import io.neonbee.internal.processor.odata.EntityExpander;
import io.neonbee.internal.processor.odata.expression.FilterExpressionVisitor;
import io.neonbee.internal.processor.odata.expression.OrderExpressionExecutor;
import io.neonbee.logging.LoggingFacade;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
Expand Down Expand Up @@ -89,21 +92,32 @@ public void readEntityCollection(ODataRequest request, ODataResponse response, U
// Fetch the data from backend
fetchEntities(request, edmEntityType, ew -> {
try {
List<Entity> resultEntityList = applyFilterQueryOption(uriInfo.getFilterOption(), ew.getEntities());
Promise<List<Entity>> applyQueryOptionsPromise = Promise.promise();

List<Entity> resultEntityList = applyFilterQueryOption(uriInfo.getFilterOption(), ew.getEntities());
if (!resultEntityList.isEmpty()) {
applyOrderByQueryOption(uriInfo.getOrderByOption(), resultEntityList);
resultEntityList = applySkipQueryOption(uriInfo.getSkipOption(), resultEntityList);
resultEntityList = applyTopQueryOption(uriInfo.getTopOption(), resultEntityList);
entityCollection.getEntities().addAll(resultEntityList);
applyExpandQueryOptions(uriInfo, resultEntityList).onComplete(applyQueryOptionsPromise);
} else {
applyQueryOptionsPromise.complete(resultEntityList);
}

EntityCollectionSerializerOptions opts = createSerializerOptions(request, uriInfo, edmEntitySet);
response.setContent(odata.createSerializer(responseFormat)
.entityCollection(serviceMetadata, edmEntityType, entityCollection, opts).getContent());
response.setStatusCode(HttpStatusCode.OK.getStatusCode());
response.setHeader(HttpHeader.CONTENT_TYPE, responseFormat.toContentTypeString());
processPromise.complete();
applyQueryOptionsPromise.future().onSuccess(finalResultEntities -> {
entityCollection.getEntities().addAll(finalResultEntities);
EntityCollectionSerializerOptions opts;
try {
opts = createSerializerOptions(request, uriInfo, edmEntitySet);
response.setContent(odata.createSerializer(responseFormat)
.entityCollection(serviceMetadata, edmEntityType, entityCollection, opts).getContent());
response.setStatusCode(HttpStatusCode.OK.getStatusCode());
response.setHeader(HttpHeader.CONTENT_TYPE, responseFormat.toContentTypeString());
processPromise.complete();
} catch (ODataException e) {
processPromise.fail(e);
}
}).onFailure(e -> processPromise.fail(e));
} catch (ODataException e) {
processPromise.fail(e);
}
Expand Down Expand Up @@ -207,15 +221,25 @@ private List<Entity> applyTopQueryOption(TopOption topOption, List<Entity> resul
return topList;
}

private Future<List<Entity>> applyExpandQueryOptions(UriInfo uriInfo, List<Entity> resultEntityList) {
return EntityExpander.create(vertx, uriInfo.getExpandOption(), routingContext).map(expander -> {
for (Entity requestedEntity : resultEntityList) {
expander.expand(requestedEntity);
}
return resultEntityList;
});
}

private EntityCollectionSerializerOptions createSerializerOptions(ODataRequest request, UriInfo uriInfo,
EdmEntitySet edmEntitySet) throws SerializerException {
String collectionId = request.getRawRequestUri().replaceFirst(request.getRawBaseUri(), EMPTY);
String selectList = odata.createUriHelper().buildContextURLSelectList(edmEntitySet.getEntityType(), null,
uriInfo.getSelectOption());
ContextURL contextUrl = ContextURL.with().entitySet(edmEntitySet).selectList(selectList).build();
String selectList = odata.createUriHelper().buildContextURLSelectList(edmEntitySet.getEntityType(),
uriInfo.getExpandOption(), uriInfo.getSelectOption());
ContextURL contextUrl =
ContextURL.with().entitySet(edmEntitySet).selectList(selectList).suffix(Suffix.ENTITY).build();

return EntityCollectionSerializerOptions.with().id(collectionId).contextURL(contextUrl)
.select(uriInfo.getSelectOption()).build();
.select(uriInfo.getSelectOption()).expand(uriInfo.getExpandOption()).build();
}

@Override
Expand Down
130 changes: 130 additions & 0 deletions src/main/java/io/neonbee/internal/processor/odata/EntityExpander.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package io.neonbee.internal.processor.odata;

import static io.neonbee.entity.EntityVerticle.requestEntity;
import static io.neonbee.internal.Helper.allComposite;
import static io.vertx.core.Future.succeededFuture;
import static java.util.stream.Collectors.toList;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.olingo.commons.api.data.Entity;
import org.apache.olingo.commons.api.data.EntityCollection;
import org.apache.olingo.commons.api.data.Link;
import org.apache.olingo.commons.api.edm.EdmEntityType;
import org.apache.olingo.commons.api.edm.EdmNavigationProperty;
import org.apache.olingo.commons.api.edm.EdmReferentialConstraint;
import org.apache.olingo.commons.api.edm.constants.EdmTypeKind;
import org.apache.olingo.server.api.uri.UriResourceNavigation;
import org.apache.olingo.server.api.uri.queryoption.ExpandOption;

import io.neonbee.data.DataQuery;
import io.neonbee.data.DataRequest;
import io.neonbee.data.internal.DataContextImpl;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.ext.web.RoutingContext;

public final class EntityExpander {
private final List<EdmNavigationProperty> navigationProperties;

private final Map<EdmEntityType, List<Entity>> fetchedEntities;

private EntityExpander(List<EdmNavigationProperty> navigationProperties,
Map<EdmEntityType, List<Entity>> fetchedEntities) {
this.navigationProperties = navigationProperties;
this.fetchedEntities = fetchedEntities;
}

/**
* Creating the EntityExpander is an asynchronous operation, because during the creation the EntityExpander fetches
* all referenced and <b>potentially</b> required entities based on the expand options. When the EntityExpander is
* created successfully, the expand of an entity happens synchronously.
*
* @param vertx The Vert.x instance
* @param expandOption The expand options of the OData request
* @param routingContext The routingContext of the request
* @return A {@link Future} holding a {@link EntityExpander} when it is completed.
*/
public static Future<EntityExpander> create(Vertx vertx, ExpandOption expandOption, RoutingContext routingContext) {
if (expandOption != null) {
List<EdmNavigationProperty> navigationProperties = getNavigationProperties(expandOption);
Map<EdmEntityType, List<Entity>> fetchedEntities = new HashMap<>();

List<Future<?>> fetchFutures =
navigationProperties.stream().map(EdmNavigationProperty::getType).distinct().map(type -> {
DataRequest req = new DataRequest(type.getFullQualifiedName(), new DataQuery());
return requestEntity(vertx, req, new DataContextImpl(routingContext))
.map(ew -> fetchedEntities.put(type, ew.getEntities()));
}).collect(toList());
return allComposite(fetchFutures).map(v -> new EntityExpander(navigationProperties, fetchedEntities));
} else {
return succeededFuture(new EntityExpander(List.of(), Map.of()));
}
}

private static List<EdmNavigationProperty> getNavigationProperties(ExpandOption expandOption) {
return expandOption.getExpandItems().stream().map(item -> item.getResourcePath().getUriResourceParts().get(0))
.filter(UriResourceNavigation.class::isInstance).map(UriResourceNavigation.class::cast)
.map(UriResourceNavigation::getProperty).collect(toList());
}

/**
* Expands the attributes of the passed Entity.
*
* @param entityToExpand The entity to expand
*/
public void expand(Entity entityToExpand) {
for (EdmNavigationProperty navigationProperty : navigationProperties) {
if (!EdmTypeKind.ENTITY.equals(navigationProperty.getType().getKind())) {
throw new UnsupportedOperationException("At the moment only type Entity can be expanded");
}

List<Entity> entitiesToLink = getRelatedEntities(entityToExpand, navigationProperty);
linkEntities(entityToExpand, navigationProperty, entitiesToLink);
}
}

private void linkEntities(Entity entity, EdmNavigationProperty navigationProperty, List<Entity> entitiesToLink) {
Link link = new Link();
link.setTitle(navigationProperty.getName());
// Reveal if navigation property is Collection or Entity
if (navigationProperty.isCollection()) {
EntityCollection expandCollection = new EntityCollection();
expandCollection.getEntities().addAll(entitiesToLink);
link.setInlineEntitySet(expandCollection);
} else {
link.setInlineEntity(entitiesToLink.get(0));
}
entity.getNavigationLinks().add(link);
}

private List<Entity> getRelatedEntities(Entity entityToExpand, EdmNavigationProperty navigationProperty) {
boolean isCollection = navigationProperty.isCollection();

List<Entity> filteredEntities = new ArrayList<>(fetchedEntities.get(navigationProperty.getType()));

List<EdmReferentialConstraint> constraints =
isCollection ? navigationProperty.getPartner().getReferentialConstraints()
: navigationProperty.getReferentialConstraints();

for (EdmReferentialConstraint constraint : constraints) {
String propertyName = isCollection ? constraint.getReferencedPropertyName() : constraint.getPropertyName();
String referencePropertyName =
isCollection ? constraint.getPropertyName() : constraint.getReferencedPropertyName();

Object propertyValue = entityToExpand.getProperty(propertyName).getValue();
List<Entity> remainingEntities = List.copyOf(filteredEntities);
filteredEntities.clear();
for (Entity referenceEntity : remainingEntities) {
Object referencePropertyValue = referenceEntity.getProperty(referencePropertyName).getValue();
if (propertyValue.equals(referencePropertyValue)) {
filteredEntities.add(referenceEntity);
}
}
}
return filteredEntities;
}
}
17 changes: 16 additions & 1 deletion src/test/java/io/neonbee/test/base/ODataRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public class ODataRequest {

private String property;

private String systemQueryExpand;

private Buffer body;

/**
Expand Down Expand Up @@ -358,6 +360,18 @@ public ODataRequest interceptRequest(Consumer<HttpRequest<Buffer>> interceptor)
return this;
}

/**
* Configure the system query expand with the passed value. The expandValue "Products" would result into
* "expand=Products".
*
* @param expandValue the value for the expand query
* @return An {@link ODataRequest} with the passed expand query
*/
public ODataRequest setExpandQuery(String expandValue) {
systemQueryExpand = "expand=" + expandValue;
return this;
}

/**
* Builds and returns the OData URL of the {@link ODataRequest} based on namespace, entity name, key predicates,
* properties, and OData suffixes {@code $metadata}, {@code $count}.
Expand All @@ -374,7 +388,8 @@ protected String getUri() {
if (count) {
return entitySet + "/$count";
}
return entitySet + getPredicate(keys) + (Strings.isNullOrEmpty(property) ? EMPTY : '/' + property);
return entitySet + getPredicate(keys) + (Strings.isNullOrEmpty(property) ? EMPTY : '/' + property)
+ (Strings.isNullOrEmpty(systemQueryExpand) ? EMPTY : "?$" + systemQueryExpand);
}

private String getPredicate(Map<String, String> keyMap) throws IllegalArgumentException {
Expand Down
7 changes: 7 additions & 0 deletions src/test/java/io/neonbee/test/base/ODataRequestTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,11 @@ public void testGetUriWithProperty() {
odataRequest.setProperty("my-property");
assertThat(odataRequest.getUri()).isEqualTo(expectedServiceRootURL + "/my-entity/my-property");
}

@Test
@DisplayName("with expand single attribute")
public void testExpand() {
odataRequest.setKey("0123").setExpandQuery("ExpandItem");
assertThat(odataRequest.getUri()).isEqualTo(expectedServiceRootURL + "/my-entity('0123')?$expand=ExpandItem");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package io.neonbee.test.endpoint.odata;

import static io.neonbee.test.endpoint.odata.verticle.NavPropsCategoriesEntityVerticle.ALL_CATEGORIES;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsCategoriesEntityVerticle.CATEGORIES_ENTITY_SET_FQN;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsCategoriesEntityVerticle.FOOD_CATEGORY;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsCategoriesEntityVerticle.MOTORCYCLE_CATEGORY;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsCategoriesEntityVerticle.addProductsToCategory;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle.ALL_PRODUCTS;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle.CHEESE_PRODUCT;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle.PRODUCTS_ENTITY_SET_FQN;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle.STEAK_PRODUCT;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle.STREET_GLIDE_SPECIAL_PRODUCT;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle.S_1000_RR_PRODUCT;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle.addCategoryToProduct;
import static io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle.getDeclaredEntityModel;

import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.TimeUnit;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import io.neonbee.test.base.ODataEndpointTestBase;
import io.neonbee.test.base.ODataRequest;
import io.neonbee.test.endpoint.odata.verticle.NavPropsCategoriesEntityVerticle;
import io.neonbee.test.endpoint.odata.verticle.NavPropsProductsEntityVerticle;
import io.vertx.core.CompositeFuture;
import io.vertx.core.json.JsonObject;
import io.vertx.junit5.Timeout;
import io.vertx.junit5.VertxTestContext;

public class ODataExpandEntityCollectionTest extends ODataEndpointTestBase {
@Override
protected List<Path> provideEntityModels() {
return List.of(getDeclaredEntityModel());
}

@BeforeEach
void setUp(VertxTestContext testContext) {
CompositeFuture
.all(deployVerticle(new NavPropsProductsEntityVerticle()),
deployVerticle(new NavPropsCategoriesEntityVerticle()))
.onComplete(testContext.succeedingThenComplete());
}

@Test
@Timeout(value = 2, timeUnit = TimeUnit.SECONDS)
@DisplayName("Expand property 'category' in Products")
void testExpandCategoryInProducts(VertxTestContext testContext) {
ODataRequest oDataRequest = new ODataRequest(PRODUCTS_ENTITY_SET_FQN).setExpandQuery("category");
List<JsonObject> expected = List.of(addCategoryToProduct(STEAK_PRODUCT, FOOD_CATEGORY),
addCategoryToProduct(CHEESE_PRODUCT, FOOD_CATEGORY),
addCategoryToProduct(S_1000_RR_PRODUCT, MOTORCYCLE_CATEGORY),
addCategoryToProduct(STREET_GLIDE_SPECIAL_PRODUCT, MOTORCYCLE_CATEGORY));

assertODataEntitySetContainsExactly(requestOData(oDataRequest), expected, testContext)
.onComplete(testContext.succeedingThenComplete());
}

@Test
@Timeout(value = 2, timeUnit = TimeUnit.SECONDS)
@DisplayName("Do not expand property 'category' in Products")
void testDoNotExpandCategoryInProducts(VertxTestContext testContext) {
ODataRequest oDataRequest = new ODataRequest(PRODUCTS_ENTITY_SET_FQN);
List<JsonObject> expected = ALL_PRODUCTS;

assertODataEntitySetContainsExactly(requestOData(oDataRequest), expected, testContext)
.onComplete(testContext.succeedingThenComplete());
}

@Test
@Timeout(value = 2, timeUnit = TimeUnit.HOURS)
@DisplayName("Expand property 'products' in Categories")
void testExpandProductsInCategory(VertxTestContext testContext) {
ODataRequest oDataRequest = new ODataRequest(CATEGORIES_ENTITY_SET_FQN).setExpandQuery("products");
List<JsonObject> expected = List.of(
addProductsToCategory(FOOD_CATEGORY, List.of(STEAK_PRODUCT, CHEESE_PRODUCT)),
addProductsToCategory(MOTORCYCLE_CATEGORY, List.of(S_1000_RR_PRODUCT, STREET_GLIDE_SPECIAL_PRODUCT)));

assertODataEntitySetContainsExactly(requestOData(oDataRequest), expected, testContext)
.onComplete(testContext.succeedingThenComplete());
}

@Test
@Timeout(value = 2, timeUnit = TimeUnit.SECONDS)
@DisplayName("Do not expand property 'products' in Categories")
void testDoNotExpandProductsInCategory(VertxTestContext testContext) {
ODataRequest oDataRequest = new ODataRequest(CATEGORIES_ENTITY_SET_FQN);
List<JsonObject> expected = ALL_CATEGORIES;

assertODataEntitySetContainsExactly(requestOData(oDataRequest), expected, testContext)
.onComplete(testContext.succeedingThenComplete());
}
}
Loading

0 comments on commit 92dbcde

Please sign in to comment.