Skip to content

Commit

Permalink
MODINVSTOR-1266 Append bounded items to invetory-view instances response
Browse files Browse the repository at this point in the history
  • Loading branch information
illia-borysenko committed Oct 30, 2024
1 parent c39808c commit 1b959ff
Show file tree
Hide file tree
Showing 6 changed files with 293 additions and 8 deletions.
6 changes: 6 additions & 0 deletions ramls/inventory-view.raml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ resourceTypes:
description: Get instances by id with their holdings and items
is: [pageable, searchable: {description: "using CQL",
example: "title=\"*uproot*\""}]
queryParameters:
withBoundedItems:
description: Add "items" records that bounded with holdings of the parent instance
type: boolean
required: false
default: false
2 changes: 1 addition & 1 deletion ramls/raml-util
113 changes: 113 additions & 0 deletions src/main/java/org/folio/persist/InstanceRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,36 @@
import static org.folio.rest.impl.ItemStorageApi.ITEM_TABLE;
import static org.folio.rest.persist.PgUtil.postgresClient;

import com.fasterxml.jackson.core.JsonProcessingException;
import io.vertx.core.Context;
import io.vertx.core.Future;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.RowSet;
import io.vertx.sqlclient.RowStream;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.folio.cql2pgjson.CQL2PgJSON;
import org.folio.cql2pgjson.exception.FieldException;
import org.folio.dbschema.ObjectMapperTool;
import org.folio.rest.exceptions.BadRequestException;
import org.folio.rest.jaxrs.model.Instance;
import org.folio.rest.jaxrs.model.ResultInfo;
import org.folio.rest.persist.SQLConnection;
import org.folio.rest.persist.cql.CQLQueryValidationException;
import org.folio.rest.persist.cql.CQLWrapper;

public class InstanceRepository extends AbstractRepository<Instance> {
public static final String INSTANCE_TABLE = "instance";
private static final String INSTANCE_SET_VIEW = "instance_set";
private static final String INSTANCE_HOLDINGS_ITEM_VIEW = "instance_holdings_item_view";
private static final String INVENTORY_VIEW_JSONB_FIELD = "inventory_view.jsonb";

public InstanceRepository(Context context, Map<String, String> okapiHeaders) {
super(postgresClient(context, okapiHeaders), INSTANCE_TABLE, Instance.class);
Expand Down Expand Up @@ -140,4 +150,107 @@ public Future<List<Map<String, Object>>> getReindexInstances(String fromId, Stri
return resultList;
});
}

public Future<Response> getInventoryViewInstancesWithBoundedItems(int offset, int limit, String query) {
try {
var sql = buildInventoryViewQueryWithBoundedItems(query, limit, offset);
return postgresClient.select(sql.toString())
.map(this::buildInventoryViewResponse);
} catch (CQLQueryValidationException e) {
return Future.failedFuture(new BadRequestException(e.getMessage()));
} catch (Exception e) {
return Future.failedFuture(e);
}
}

private StringBuilder buildInventoryViewQueryWithBoundedItems(String query, int limit, int offset) {
var sql = new StringBuilder("SELECT JSONB_BUILD_OBJECT(");
sql.append("'instanceId', inventory_view.jsonb->>'instanceId', ");
sql.append("'isBoundWith', inventory_view.jsonb->'isBoundWith', ");
sql.append("'instance', inventory_view.jsonb->'instance', ");
sql.append("'holdingsRecords', inventory_view.jsonb->'holdingsRecords', ");
sql.append("'items', ").append(selectItemsWithBoundedRecords()).append(") AS jsonb ");
sql.append("FROM ");
sql.append(postgresClientFuturized.getFullTableName(INSTANCE_HOLDINGS_ITEM_VIEW));
sql.append(" AS inventory_view ");
sql.append(appendCqlQuery(query, limit, offset));
return sql;
}

private Response buildInventoryViewResponse(RowSet<Row> rowSet) {
try {
var jsonResponse = createInventoryViewJsonResponse(rowSet);
return Response.ok(jsonResponse.encode(), MediaType.APPLICATION_JSON_TYPE).build();
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}

private JsonObject createInventoryViewJsonResponse(RowSet<Row> rowSet) throws JsonProcessingException {
var instances = new JsonArray();
rowSet.forEach(row -> {
var rowJsonValue = row.getJsonObject(0);
instances.add(filterNullFields(rowJsonValue));
});
var totalRecords = rowSet.size();
var resultInfoString = ObjectMapperTool.getMapper().writeValueAsString(
new ResultInfo().withTotalRecords(totalRecords));
return new JsonObject()
.put("instances", instances)
.put("totalRecords", totalRecords)
.put("resultInfo", new JsonObject(resultInfoString));
}

private StringBuilder selectItemsWithBoundedRecords() {
var sql = new StringBuilder("(SELECT jsonb_agg(combined_items.jsonb) FROM (");
sql.append(selectItemsByInstance());
sql.append(" UNION ");
sql.append(selectBoundedItems());
sql.append(") AS combined_items)");
return sql;
}

private String appendCqlQuery(String query, int limit, int offset) {
try {
var field = new CQL2PgJSON(INVENTORY_VIEW_JSONB_FIELD);
var cqlWrapper = new CQLWrapper(field, query, limit, offset);
return cqlWrapper.toString();
} catch (FieldException e) {
throw new RuntimeException(e);
}
}

private StringBuilder selectItemsByInstance() {
var sql = new StringBuilder("SELECT item.jsonb FROM ");
sql.append(postgresClientFuturized.getFullTableName(HOLDINGS_RECORD_TABLE));
sql.append(" AS hr JOIN ");
sql.append(postgresClientFuturized.getFullTableName(ITEM_TABLE));
sql.append(" ON item.holdingsRecordId = hr.id AND hr.instanceId = inventory_view.id ");
return sql;
}

private StringBuilder selectBoundedItems() {
var sql = new StringBuilder("SELECT item.jsonb FROM ");
sql.append(postgresClientFuturized.getFullTableName(ITEM_TABLE));
sql.append(" JOIN ");
sql.append(postgresClientFuturized.getFullTableName(BOUND_WITH_TABLE));
sql.append(" AS bwp ON item.id = bwp.itemid ");
sql.append("JOIN ");
sql.append(postgresClientFuturized.getFullTableName(HOLDINGS_RECORD_TABLE));
sql.append(" AS hr ON hr.id = bwp.holdingsrecordid AND hr.instanceId = inventory_view.id");
return sql;
}

private JsonObject filterNullFields(JsonObject rowJsonValue) {
return rowJsonValue
.stream()
.filter(field -> field.getValue() != null)
.collect(
Collectors.collectingAndThen(
Collectors.toMap(Entry::getKey, Entry::getValue),
JsonObject::new
)
);
}

}
23 changes: 16 additions & 7 deletions src/main/java/org/folio/rest/impl/InventoryViewApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,26 @@
import javax.ws.rs.core.Response;
import org.folio.rest.annotations.Validate;
import org.folio.rest.jaxrs.resource.InventoryViewInstances;
import org.folio.rest.support.EndpointHandler;
import org.folio.services.instance.InstanceService;

public class InventoryViewApi implements InventoryViewInstances {
@Validate
@Override
public void getInventoryViewInstances(String totalRecords, int offset, int limit, String query,
RoutingContext routingContext, Map<String, String> okapiHeaders,
Handler<AsyncResult<Response>> asyncResultHandler, Context vertxContext) {

new InstanceService(vertxContext, okapiHeaders)
.streamGetInventoryViewInstances("instance_holdings_item_view", query,
offset, limit, null, "instances", 0, routingContext);
public void getInventoryViewInstances(boolean withBoundedItems,
String totalRecords, int offset, int limit,
String query, RoutingContext routingContext,
Map<String, String> okapiHeaders,
Handler<AsyncResult<Response>> asyncResultHandler, Context vertxContext) {
var instanceService = new InstanceService(vertxContext, okapiHeaders);
if (withBoundedItems) {
instanceService
.getInventoryViewInstancesWithBoundedItems(offset, limit, query)
.onComplete(EndpointHandler.handle(asyncResultHandler));
} else {
instanceService
.streamGetInventoryViewInstances("instance_holdings_item_view", query,
offset, limit, null, "instances", 0, routingContext);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ public Future<Response> getInstanceSet(boolean instance, boolean holdingsRecords
offset, limit, query);
}

public Future<Response> getInventoryViewInstancesWithBoundedItems(int offset, int limit, String query) {
return instanceRepository.getInventoryViewInstancesWithBoundedItems(offset, limit, query);
}

public Future<Response> createInstance(Instance entity) {
entity.setStatusUpdatedDate(generateStatusUpdatedDate());
return hridManager.populateHrid(entity)
Expand Down
153 changes: 153 additions & 0 deletions src/test/java/org/folio/rest/api/InventoryViewTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.Assert.assertTrue;

import io.vertx.core.json.JsonObject;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import org.folio.rest.jaxrs.model.HoldingsRecord;
import org.folio.rest.jaxrs.model.InventoryViewInstance;
import org.folio.rest.jaxrs.model.Item;
import org.folio.rest.support.IndividualResource;
import org.folio.util.StringUtil;
import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.junit.Test;
Expand Down Expand Up @@ -108,6 +110,140 @@ public void shouldReturnInstanceEvenIfNoHoldings() {
}
}

@Test
public void shouldReturnInstanceWithBoundedItemsRecords_whenWithBoundedItemsTrue() {
//given
var instanceOne = instancesClient.create(instance(randomUUID()));
var holdingsForOne = List.of(
createHolding(instanceOne.getId(), MAIN_LIBRARY_LOCATION_ID, null),
createHolding(instanceOne.getId(), SECOND_FLOOR_LOCATION_ID, null)
);
var itemsForOne = List.of(
createItem(nodWithNoBarcode(holdingsForOne.get(0))).getString("id"),
createItem(nodWithNoBarcode(holdingsForOne.get(1))).getString("id")
);

var instanceTwo = instancesClient.create(instance(randomUUID()));
var holdingsForTwo = List.of(
createHolding(instanceTwo.getId(), MAIN_LIBRARY_LOCATION_ID, null));
final var itemsForTwo = List.of(
createItem(nodWithNoBarcode(holdingsForTwo.get(0))).getString("id"),
itemsForOne.get(1));

boundWithClient.create(
createBoundWithPartJson(holdingsForTwo.get(0).toString(), itemsForOne.get(1)));

//when
String query = getQueryWithBoundedItems(instanceOne, instanceTwo, "id==(%s or %s)");
var instances = inventoryViewClient.getByQuery(query)
.stream()
.map(IndividualResource::new)
.toList();

//then
assertThat(instances.size(), is(2));

var firstInstance = getInstanceById(instances, instanceOne.getId());
var secondInstance = getInstanceById(instances, instanceTwo.getId());

assertThat(getHoldingIds(firstInstance), matchesInAnyOrder(holdingsForOne));
assertThat(getItemIds(firstInstance), matchesInAnyOrder(itemsForOne));

assertThat(getHoldingIds(secondInstance), matchesInAnyOrder(holdingsForTwo));
assertThat(getItemIds(secondInstance), matchesInAnyOrder(itemsForTwo));
}

@Test
public void shouldReturnInstanceEvenIfNoItems_whenWithBoundedItemsTrue() {
//given
var instanceOne = instancesClient.create(instance(randomUUID()));
var holdingForOne = createHolding(instanceOne.getId(), MAIN_LIBRARY_LOCATION_ID, null);

var instanceTwo = instancesClient.create(instance(randomUUID()));
var holdingsForTwo = List.of(
createHolding(instanceTwo.getId(), MAIN_LIBRARY_LOCATION_ID, null),
createHolding(instanceTwo.getId(), SECOND_FLOOR_LOCATION_ID, null),
createHolding(instanceTwo.getId(), FOURTH_FLOOR_LOCATION_ID, null));

//when
String query = getQueryWithBoundedItems(instanceOne, instanceTwo, "id==(%s or %s)");
var instances = inventoryViewClient.getByQuery(query)
.stream()
.map(IndividualResource::new)
.toList();

//then
assertThat(instances.size(), is(2));

var firstInstance = getInstanceById(instances, instanceOne.getId());
var secondInstance = getInstanceById(instances, instanceTwo.getId());

assertThat(firstInstance.getHoldingsRecords().get(0).getId(), is(holdingForOne.toString()));
assertThat(getHoldingIds(secondInstance), matchesInAnyOrder(holdingsForTwo));

isNonNullEmpty(firstInstance.getItems());
isNonNullEmpty(secondInstance.getItems());
}

@Test
public void shouldReturnInstanceEvenIfNoHoldings_whenWithBoundedItemsTrue() {
//given
var instanceOne = instancesClient.create(instance(randomUUID()));
var instanceTwo = instancesClient.create(instance(randomUUID()));

//when
String query = getQueryWithBoundedItems(instanceOne, instanceTwo, "id==(%s or %s)");
var instances = inventoryViewClient.getByQuery(query)
.stream()
.map(IndividualResource::new)
.toList();

//then
assertThat(instances.size(), is(2));

var returnedInstances = instances.stream()
.map(resource -> resource.getJson().mapTo(InventoryViewInstance.class))
.toList();

for (InventoryViewInstance returnedInstance : returnedInstances) {
isNonNullEmpty(returnedInstance.getHoldingsRecords());
isNonNullEmpty(returnedInstance.getItems());

assertTrue(returnedInstance.getInstanceId().equals(instanceOne.getId().toString())
|| returnedInstance.getInstanceId().equals(instanceTwo.getId().toString()));
}
}

@Test
public void shouldReturnNotSuppressedInstanceEvenIfNoHoldings_whenWithBoundedItemsTrue() {
//given
var instanceOne = instancesClient.create(instance(randomUUID()));
var instanceTwo = instancesClient.create(instance(randomUUID()));

//when
String query = getQueryWithBoundedItems(instanceOne, instanceTwo,
"instance.discoverySuppress<>true and id==(%s or %s)");
var instances = inventoryViewClient.getByQuery(query)
.stream()
.map(IndividualResource::new)
.toList();

//then
assertThat(instances.size(), is(2));

var returnedInstances = instances.stream()
.map(resource -> resource.getJson().mapTo(InventoryViewInstance.class))
.toList();

for (InventoryViewInstance returnedInstance : returnedInstances) {
isNonNullEmpty(returnedInstance.getHoldingsRecords());
isNonNullEmpty(returnedInstance.getItems());

assertTrue(returnedInstance.getInstanceId().equals(instanceOne.getId().toString())
|| returnedInstance.getInstanceId().equals(instanceTwo.getId().toString()));
}
}

private List<UUID> getHoldingIds(InventoryViewInstance instance) {
return instance.getHoldingsRecords().stream()
.map(HoldingsRecord::getId)
Expand Down Expand Up @@ -143,4 +279,21 @@ private InventoryViewInstance getInstanceById(List<IndividualResource> instances

return instance;
}

private JsonObject createBoundWithPartJson(String holdingsRecordId, String itemId) {
JsonObject boundWithPart = new JsonObject();
boundWithPart.put("holdingsRecordId", holdingsRecordId);
boundWithPart.put("itemId", itemId);
return boundWithPart;
}

private String getQueryWithBoundedItems(IndividualResource instanceOne,
IndividualResource instanceTwo, String cqlParams) {
var encodedCqlParams = StringUtil.urlEncode(
String.format(cqlParams, instanceTwo.getId(), instanceOne.getId()));
var query = String.format("?withBoundedItems=true&query=%s",
encodedCqlParams);
return query;
}

}

0 comments on commit 1b959ff

Please sign in to comment.