From aa29d6387be9104483c3dff1c7e0be1a35946f63 Mon Sep 17 00:00:00 2001 From: Illia Borysenko Date: Fri, 25 Oct 2024 11:58:46 +0300 Subject: [PATCH] MODINVSTOR-1266 Append bounded items to invetory-view instances response --- ramls/inventory-view.raml | 6 + .../org/folio/persist/InstanceRepository.java | 113 +++++++++++++ .../org/folio/rest/impl/InventoryViewApi.java | 24 ++- .../services/instance/InstanceService.java | 4 + .../org/folio/rest/api/InventoryViewTest.java | 153 ++++++++++++++++++ 5 files changed, 294 insertions(+), 6 deletions(-) diff --git a/ramls/inventory-view.raml b/ramls/inventory-view.raml index 1d25d5268..14841e795 100644 --- a/ramls/inventory-view.raml +++ b/ramls/inventory-view.raml @@ -29,3 +29,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/src/main/java/org/folio/persist/InstanceRepository.java b/src/main/java/org/folio/persist/InstanceRepository.java index 9abafe6b8..86ce4901e 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 { + StringBuilder 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) { + StringBuilder 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 { + JsonObject 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 { + JsonArray instances = new JsonArray(); + rowSet.forEach(row -> { + var rowJsonValue = row.getJsonObject(0); + instances.add(filterNullFields(rowJsonValue)); + }); + int totalRecords = rowSet.size(); + String 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() { + StringBuilder 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 { + CQL2PgJSON field = new CQL2PgJSON(INVENTORY_VIEW_JSONB_FIELD); + CQLWrapper cqlWrapper = new CQLWrapper(field, query, limit, offset); + return cqlWrapper.toString(); + } catch (FieldException e) { + throw new RuntimeException(e); + } + } + + private StringBuilder selectItemsByInstance() { + StringBuilder 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() { + StringBuilder 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 b7dd7aae9..8591a941a 100644 --- a/src/main/java/org/folio/rest/impl/InventoryViewApi.java +++ b/src/main/java/org/folio/rest/impl/InventoryViewApi.java @@ -11,15 +11,27 @@ import org.folio.rest.annotations.Validate; import org.folio.rest.jaxrs.model.InventoryViewInstance; 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) { - - streamGet("instance_holdings_item_view", InventoryViewInstance.class, query, - offset, limit, null, "instances", routingContext, okapiHeaders, vertxContext); + public void getInventoryViewInstances(boolean withBoundedItems, + String totalRecords, int offset, int limit, + String query, RoutingContext routingContext, + Map okapiHeaders, + Handler> asyncResultHandler, Context vertxContext) { + if (withBoundedItems) { + new InstanceService(vertxContext, okapiHeaders) + .getInventoryViewInstancesWithBoundedItems(offset, limit, query) + .onComplete(EndpointHandler.handle(asyncResultHandler)); + } else { + streamGet("instance_holdings_item_view", InventoryViewInstance.class, + query, + offset, limit, null, "instances", routingContext, okapiHeaders, + vertxContext); + } } + } diff --git a/src/main/java/org/folio/services/instance/InstanceService.java b/src/main/java/org/folio/services/instance/InstanceService.java index 8a37b6a3e..e2b30f0ea 100644 --- a/src/main/java/org/folio/services/instance/InstanceService.java +++ b/src/main/java/org/folio/services/instance/InstanceService.java @@ -88,6 +88,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..5f985f74f 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; + } + }