diff --git a/ramls/inventory-view.raml b/ramls/inventory-view.raml index 59a5fab6d..ee696803c 100644 --- a/ramls/inventory-view.raml +++ b/ramls/inventory-view.raml @@ -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 diff --git a/ramls/raml-util b/ramls/raml-util index c113f109d..f48a63f3e 160000 --- a/ramls/raml-util +++ b/ramls/raml-util @@ -1 +1 @@ -Subproject commit c113f109d1379d15230f3f8a3485e61ffccc0ad8 +Subproject commit f48a63f3e45b9a3b437d21c3a61ed10b8ceb5f25 diff --git a/src/main/java/org/folio/persist/InstanceRepository.java b/src/main/java/org/folio/persist/InstanceRepository.java index 9abafe6b8..39ab01170 100644 --- a/src/main/java/org/folio/persist/InstanceRepository.java +++ b/src/main/java/org/folio/persist/InstanceRepository.java @@ -5,19 +5,27 @@ 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; @@ -25,6 +33,8 @@ public class InstanceRepository extends AbstractRepository { 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 okapiHeaders) { super(postgresClient(context, okapiHeaders), INSTANCE_TABLE, Instance.class); @@ -140,4 +150,107 @@ public Future>> getReindexInstances(String fromId, Stri return resultList; }); } + + public Future 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 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 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 + ) + ); + } + } diff --git a/src/main/java/org/folio/rest/impl/InventoryViewApi.java b/src/main/java/org/folio/rest/impl/InventoryViewApi.java index 325055ca2..ac508ec02 100644 --- a/src/main/java/org/folio/rest/impl/InventoryViewApi.java +++ b/src/main/java/org/folio/rest/impl/InventoryViewApi.java @@ -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 okapiHeaders, - Handler> 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 okapiHeaders, + Handler> 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); + } } } diff --git a/src/main/java/org/folio/services/instance/InstanceService.java b/src/main/java/org/folio/services/instance/InstanceService.java index 8360a27ef..96a3ada2f 100644 --- a/src/main/java/org/folio/services/instance/InstanceService.java +++ b/src/main/java/org/folio/services/instance/InstanceService.java @@ -94,6 +94,10 @@ public Future getInstanceSet(boolean instance, boolean holdingsRecords offset, limit, query); } + public Future getInventoryViewInstancesWithBoundedItems(int offset, int limit, String query) { + return instanceRepository.getInventoryViewInstancesWithBoundedItems(offset, limit, query); + } + public Future createInstance(Instance entity) { entity.setStatusUpdatedDate(generateStatusUpdatedDate()); return hridManager.populateHrid(entity) diff --git a/src/test/java/org/folio/rest/api/InventoryViewTest.java b/src/test/java/org/folio/rest/api/InventoryViewTest.java index 871a8e32f..7ee2d7c94 100644 --- a/src/test/java/org/folio/rest/api/InventoryViewTest.java +++ b/src/test/java/org/folio/rest/api/InventoryViewTest.java @@ -9,6 +9,7 @@ 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; @@ -16,6 +17,7 @@ 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; @@ -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 getHoldingIds(InventoryViewInstance instance) { return instance.getHoldingsRecords().stream() .map(HoldingsRecord::getId) @@ -143,4 +279,21 @@ private InventoryViewInstance getInstanceById(List 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; + } + }