From 77f740d1f6e4b6df11ee56f13ea2db3064e95da5 Mon Sep 17 00:00:00 2001 From: psmagin Date: Thu, 14 Mar 2024 16:02:36 +0200 Subject: [PATCH] feat(search-instances): implement endpoint for consolidate items access in consortium (#535) Closes: MSEARCH-693 Signed-off-by: psmagin --- NEWS.md | 1 + README.md | 1 + .../SearchConsortiumController.java | 19 +++ .../service/ConsortiumSearchContext.java | 17 ++- .../search/model/types/ResourceType.java | 1 + .../ConsortiumInstanceRepository.java | 14 +++ .../consortium/ConsortiumInstanceService.java | 7 ++ .../ConsortiumSearchQueryBuilder.java | 64 +++++++--- .../resources/swagger.api/mod-search.yaml | 79 +++++++++++++ .../resources/swagger.api/schemas/item.json | 4 + .../controller/SearchItemsConsortiumIT.java | 111 ++++++++++++++++++ .../ConsortiumSearchQueryBuilderTest.java | 50 +++++++- .../search/support/base/ApiEndpoints.java | 19 ++- .../samples/semantic-web-primer/items.json | 36 ++++++ 14 files changed, 394 insertions(+), 29 deletions(-) create mode 100644 src/test/java/org/folio/search/controller/SearchItemsConsortiumIT.java diff --git a/NEWS.md b/NEWS.md index 568d968e3..f9e61f58b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -22,6 +22,7 @@ * Authority search: Modify query search option to search authorities by normalized LCCN ([MSEARCH-663](https://issues.folio.org/browse/MSEARCH-663)) * Add ability to case-insensitive search ISSNs with trailing roman numerals ([MSEARCH-672](https://folio-org.atlassian.net/browse/MSEARCH-672)) * implement endpoint for consolidate holdings access in consortium ([MSEARCH-692](https://folio-org.atlassian.net/browse/MSEARCH-692)) +* implement endpoint for consolidate items access in consortium ([MSEARCH-693](https://folio-org.atlassian.net/browse/MSEARCH-693)) ### Bug fixes * Fix secure setup of system users by default ([MSEARCH-608](https://issues.folio.org/browse/MSEARCH-608)) diff --git a/README.md b/README.md index 4da21d5fa..2aab342c6 100644 --- a/README.md +++ b/README.md @@ -854,6 +854,7 @@ Special API that provide consolidated access to records in consortium environmen | METHOD | URL | DESCRIPTION | |:-------|:------------------------------|:------------------------------| | GET | `/search/consortium/holdings` | Returns consolidated holdings | +| GET | `/search/consortium/items` | Returns consolidated items | ## Additional Information diff --git a/src/main/java/org/folio/search/controller/SearchConsortiumController.java b/src/main/java/org/folio/search/controller/SearchConsortiumController.java index e5813ab7c..69f77425e 100644 --- a/src/main/java/org/folio/search/controller/SearchConsortiumController.java +++ b/src/main/java/org/folio/search/controller/SearchConsortiumController.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.folio.search.domain.dto.ConsortiumHoldingCollection; +import org.folio.search.domain.dto.ConsortiumItemCollection; import org.folio.search.domain.dto.SortOrder; import org.folio.search.exception.RequestValidationException; import org.folio.search.model.service.ConsortiumSearchContext; @@ -44,6 +45,24 @@ public ResponseEntity getConsortiumHoldings(String return ResponseEntity.ok(instanceService.fetchHoldings(context)); } + @Override + public ResponseEntity getConsortiumItems(String tenantHeader, String instanceId, + String holdingsRecordId, String tenantId, + Integer limit, Integer offset, String sortBy, + SortOrder sortOrder) { + checkAllowance(tenantHeader); + var context = ConsortiumSearchContext.builderFor(ResourceType.ITEM) + .filter("instanceId", instanceId) + .filter("tenantId", tenantId) + .filter("holdingsRecordId", holdingsRecordId) + .limit(limit) + .offset(offset) + .sortBy(sortBy) + .sortOrder(sortOrder) + .build(); + return ResponseEntity.ok(instanceService.fetchItems(context)); + } + private void checkAllowance(String tenantHeader) { var centralTenant = consortiumTenantService.getCentralTenant(tenantHeader); if (centralTenant.isEmpty() || !centralTenant.get().equals(tenantHeader)) { diff --git a/src/main/java/org/folio/search/model/service/ConsortiumSearchContext.java b/src/main/java/org/folio/search/model/service/ConsortiumSearchContext.java index 332df9f52..5ea0ed21b 100644 --- a/src/main/java/org/folio/search/model/service/ConsortiumSearchContext.java +++ b/src/main/java/org/folio/search/model/service/ConsortiumSearchContext.java @@ -15,14 +15,12 @@ public class ConsortiumSearchContext { static final String SORT_NOT_ALLOWED_MSG = "Not allowed sort field for %s"; static final String FILTER_REQUIRED_MSG = "At least one filter criteria required"; + static final String INSTANCE_ID_FILTER_REQUIRED_MSG = "instanceId filter is required"; private static final Map> ALLOWED_SORT_FIELDS = Map.of( ResourceType.HOLDINGS, List.of("id", "hrid", "tenantId", "instanceId", - "callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId") - ); - - private static final Map DEFAULT_SORT_FIELD = Map.of( - ResourceType.HOLDINGS, "id" + "callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId"), + ResourceType.ITEM, List.of("id", "hrid", "tenantId", "instanceId", "holdingsRecordId", "barcode") ); private final ResourceType resourceType; @@ -36,6 +34,13 @@ public class ConsortiumSearchContext { String sortBy, SortOrder sortOrder) { this.resourceType = resourceType; this.filters = filters; + + if (ResourceType.ITEM == resourceType) { + boolean instanceIdFilterExist = filters.stream().anyMatch(filter -> filter.getFirst().equals("instanceId")); + if (!instanceIdFilterExist) { + throw new RequestValidationException(INSTANCE_ID_FILTER_REQUIRED_MSG, null, null); + } + } if (sortBy != null && !ALLOWED_SORT_FIELDS.get(resourceType).contains(sortBy)) { throw new RequestValidationException(SORT_NOT_ALLOWED_MSG.formatted(resourceType.getValue()), "sortBy", sortBy); } @@ -54,7 +59,7 @@ public static ConsortiumSearchContextBuilder builderFor(ResourceType resourceTyp public static class ConsortiumSearchContextBuilder { private final ResourceType resourceType; - private List> filters = new ArrayList<>(); + private final List> filters = new ArrayList<>(); private Integer limit; private Integer offset; private String sortBy; diff --git a/src/main/java/org/folio/search/model/types/ResourceType.java b/src/main/java/org/folio/search/model/types/ResourceType.java index 2dc260693..6fcc881bc 100644 --- a/src/main/java/org/folio/search/model/types/ResourceType.java +++ b/src/main/java/org/folio/search/model/types/ResourceType.java @@ -7,6 +7,7 @@ public enum ResourceType { INSTANCE("instance"), HOLDINGS("holdings"), + ITEM("item"), AUTHORITY("authority"), CLASSIFICATION_TYPE("classification-type"); diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java index d17a0b8ac..19acc34a9 100644 --- a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java +++ b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceRepository.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.folio.search.domain.dto.ConsortiumHolding; +import org.folio.search.domain.dto.ConsortiumItem; import org.folio.search.model.types.ResourceType; import org.folio.spring.FolioExecutionContext; import org.springframework.jdbc.core.JdbcTemplate; @@ -92,6 +93,19 @@ public List fetchHoldings(ConsortiumSearchQueryBuilder search ); } + public List fetchItems(ConsortiumSearchQueryBuilder searchQueryBuilder) { + return jdbcTemplate.query(searchQueryBuilder.buildSelectQuery(context), + (rs, rowNum) -> new ConsortiumItem() + .id(rs.getString("id")) + .hrid(rs.getString("hrid")) + .tenantId(rs.getString("tenantId")) + .instanceId(rs.getString("instanceId")) + .holdingsRecordId(rs.getString("holdingsRecordId")) + .barcode(rs.getString("barcode")), + searchQueryBuilder.getQueryArguments() + ); + } + private ConsortiumInstance toConsortiumInstance(ResultSet rs) throws SQLException { var id = new ConsortiumInstanceId(rs.getString(TENANT_ID_COLUMN), rs.getString(INSTANCE_ID_COLUMN)); return new ConsortiumInstance(id, rs.getString(JSON_COLUMN)); diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceService.java b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceService.java index fa65f9480..a10ff22d2 100644 --- a/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceService.java +++ b/src/main/java/org/folio/search/service/consortium/ConsortiumInstanceService.java @@ -16,6 +16,8 @@ import org.apache.commons.collections.ListUtils; import org.folio.search.domain.dto.ConsortiumHolding; import org.folio.search.domain.dto.ConsortiumHoldingCollection; +import org.folio.search.domain.dto.ConsortiumItem; +import org.folio.search.domain.dto.ConsortiumItemCollection; import org.folio.search.domain.dto.ResourceEvent; import org.folio.search.domain.dto.ResourceEventType; import org.folio.search.model.event.ConsortiumInstanceEvent; @@ -155,6 +157,11 @@ public ConsortiumHoldingCollection fetchHoldings(ConsortiumSearchContext context return new ConsortiumHoldingCollection().holdings(holdingList).totalRecords(holdingList.size()); } + public ConsortiumItemCollection fetchItems(ConsortiumSearchContext context) { + List itemList = repository.fetchItems(new ConsortiumSearchQueryBuilder(context)); + return new ConsortiumItemCollection().items(itemList).totalRecords(itemList.size()); + } + @SuppressWarnings("unchecked") private void addListItems(List> mergedList, Map instanceMap, String key) { var items = instanceMap.get(key); diff --git a/src/main/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilder.java b/src/main/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilder.java index e2897cd04..ec3110bee 100644 --- a/src/main/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilder.java +++ b/src/main/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilder.java @@ -1,14 +1,18 @@ package org.folio.search.service.consortium; +import static java.util.Collections.emptyList; import static org.apache.commons.lang3.StringUtils.EMPTY; import static org.apache.commons.lang3.StringUtils.SPACE; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.wrap; import static org.folio.search.utils.JdbcUtils.getFullTableName; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.folio.search.model.Pair; import org.folio.search.model.service.ConsortiumSearchContext; @@ -20,32 +24,51 @@ public class ConsortiumSearchQueryBuilder { static final String CONSORTIUM_INSTANCE_TABLE_NAME = "consortium_instance"; public static final Map CONSORTIUM_TABLES = Map.of( ResourceType.INSTANCE, CONSORTIUM_INSTANCE_TABLE_NAME, - ResourceType.HOLDINGS, CONSORTIUM_INSTANCE_TABLE_NAME + ResourceType.HOLDINGS, CONSORTIUM_INSTANCE_TABLE_NAME, + ResourceType.ITEM, CONSORTIUM_INSTANCE_TABLE_NAME ); private static final Map> RESOURCE_FIELDS = Map.of( ResourceType.HOLDINGS, - List.of("id", "hrid", "callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId", "discoverySuppress") + List.of("id", "hrid", "callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId", "discoverySuppress"), + ResourceType.ITEM, + List.of("id", "hrid", "holdingsRecordId", "barcode") ); private static final Map> RESOURCE_FILTER_DATABASE_NAME = Map.of( - ResourceType.HOLDINGS, Map.of("instanceId", "instance_id", "tenantId", "tenant_id") + ResourceType.HOLDINGS, Map.of("instanceId", "instance_id", "tenantId", "tenant_id"), + ResourceType.ITEM, Map.of("instanceId", "instance_id", "tenantId", "tenant_id") + ); + + private static final Map> RESOURCE_JSONB_FILTERS = Map.of( + ResourceType.ITEM, List.of("holdingsRecordId") + ); + + private static final Map RESOURCE_COLLECTION_NAME = Map.of( + ResourceType.HOLDINGS, "holdings", + ResourceType.ITEM, "items" ); private final ConsortiumSearchContext searchContext; + private final ResourceType resourceType; private final List> filters; + private final List> jsonbFilters; public ConsortiumSearchQueryBuilder(ConsortiumSearchContext searchContext) { this.searchContext = searchContext; - this.filters = prepareFilters(searchContext.getResourceType()); + this.resourceType = searchContext.getResourceType(); + this.filters = prepareFilters(resourceType, emptyList(), RESOURCE_JSONB_FILTERS.get(resourceType)); + this.jsonbFilters = prepareFilters(resourceType, RESOURCE_JSONB_FILTERS.get(resourceType), + RESOURCE_FILTER_DATABASE_NAME.get(resourceType).values()); } public String buildSelectQuery(FolioExecutionContext context) { - var resourceType = searchContext.getResourceType(); var fullTableName = getFullTableName(context, CONSORTIUM_TABLES.get(resourceType)); - String subQuery = "SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings FROM " - + fullTableName + SPACE + getWhereClause(filters); + var resourceCollection = RESOURCE_COLLECTION_NAME.get(resourceType); + String subQuery = "SELECT instance_id, tenant_id, json_array_elements(json -> '" + resourceCollection + "') " + + "as " + resourceCollection + " FROM " + fullTableName + SPACE + getWhereClause(filters, null); String query = "SELECT i.instance_id as instanceId, i.tenant_id as tenantId," - + getSelectors("i.holdings", RESOURCE_FIELDS.get(resourceType)) + + getSelectors("i." + resourceCollection, RESOURCE_FIELDS.get(resourceType)) + " FROM (" + subQuery + ") i" + + getWhereClause(jsonbFilters, "i." + resourceCollection) + getOrderByClause() + getLimitClause() + getOffsetClause(); @@ -53,7 +76,7 @@ public String buildSelectQuery(FolioExecutionContext context) { } public Object[] getQueryArguments() { - return filters.stream() + return Stream.concat(filters.stream(), jsonbFilters.stream()) .map(Pair::getSecond) .toArray(); } @@ -95,17 +118,20 @@ private String getSelectors(String source, List sourceFields) { .collect(Collectors.joining(", ")), ' '); } - private String getWhereClause(List> filters) { + private String getWhereClause(List> filters, String source) { if (filters.isEmpty()) { return EMPTY; } var conditionsClause = filters.stream() - .map(filter -> filter.getFirst() + " = ?") + .map(filter -> (StringUtils.isNotBlank(source) + ? getJsonSelector(source, filter.getFirst()) + : filter.getFirst()) + " = ?") .collect(Collectors.joining(" AND ")); - return conditionsClause.isBlank() ? conditionsClause : "WHERE " + conditionsClause; + return conditionsClause.isBlank() ? conditionsClause : wrapped("WHERE " + conditionsClause); } - private List> prepareFilters(ResourceType resourceType) { + private List> prepareFilters(ResourceType resourceType, + List includeFilters, Collection excludeFilters) { var mappedFilterNames = RESOURCE_FILTER_DATABASE_NAME.get(resourceType); return searchContext.getFilters().stream() .map(filter -> { @@ -113,7 +139,17 @@ private List> prepareFilters(ResourceType resourceType) { return Pair.pair(mappedFilterNames.get(filter.getFirst()), filter.getSecond()); } return filter; - }).toList(); + }) + .filter(filter -> { + if (CollectionUtils.isNotEmpty(includeFilters)) { + return includeFilters.contains(filter.getFirst()); + } + if (CollectionUtils.isNotEmpty(excludeFilters)) { + return !excludeFilters.contains(filter.getFirst()); + } + return true; + }) + .toList(); } } diff --git a/src/main/resources/swagger.api/mod-search.yaml b/src/main/resources/swagger.api/mod-search.yaml index 2a33c0487..cbb867080 100644 --- a/src/main/resources/swagger.api/mod-search.yaml +++ b/src/main/resources/swagger.api/mod-search.yaml @@ -228,6 +228,33 @@ paths: '500': $ref: '#/components/responses/internalServerErrorResponse' + /search/consortium/items: + get: + operationId: getConsortiumItems + description: Get a list of items (only for consortium environment) + tags: + - search-consortium + parameters: + - $ref: '#/components/parameters/instance-id-query-param' + - $ref: '#/components/parameters/holdings-id-query-param' + - $ref: '#/components/parameters/tenant-id-query-param' + - $ref: '#/components/parameters/consortium-limit-param' + - $ref: '#/components/parameters/offset-param' + - $ref: '#/components/parameters/sort-by-item-param' + - $ref: '#/components/parameters/sort-order-param' + - $ref: '#/components/parameters/x-okapi-tenant-header' + responses: + '200': + description: List of items + content: + application/json: + schema: + $ref: '#/components/schemas/consortiumItemCollection' + '400': + $ref: '#/components/responses/badRequestResponse' + '500': + $ref: '#/components/responses/internalServerErrorResponse' + /browse/call-numbers/instances: get: operationId: browseInstancesByCallNumber @@ -790,6 +817,36 @@ components: $ref: '#/components/schemas/consortiumHolding' totalRecords: type: integer + consortiumItem: + type: object + properties: + id: + description: Item ID + type: string + hrid: + description: Item HRID + type: string + tenantId: + description: Tenant ID of the Item + type: string + instanceId: + description: Related Instance Id + type: string + holdingsRecordId: + description: Related Holding Record Id + type: string + barcode: + description: Item barcode + type: string + consortiumItemCollection: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/consortiumItem' + totalRecords: + type: integer sortOrder: type: string enum: @@ -941,6 +998,13 @@ components: required: false schema: type: string + holdings-id-query-param: + in: query + name: holdingsRecordId + description: UUID of the holdings record + required: false + schema: + type: string tenant-id-query-param: in: query name: tenantId @@ -965,6 +1029,21 @@ components: required: false schema: type: string + sort-by-item-param: + in: query + name: sortBy + description: | + Defines a field to sort by. + Possible values: + - id + - hrid + - tenantId + - instanceId + - holdingsRecordId + - barcode + required: false + schema: + type: string sort-order-param: in: query name: sortOrder diff --git a/src/main/resources/swagger.api/schemas/item.json b/src/main/resources/swagger.api/schemas/item.json index 5b5200794..f7de452f5 100644 --- a/src/main/resources/swagger.api/schemas/item.json +++ b/src/main/resources/swagger.api/schemas/item.json @@ -11,6 +11,10 @@ "description": "Tenant ID", "type": "string" }, + "holdingsRecordId": { + "description": "Holdings record ID", + "type": "string" + }, "hrid": { "type": "string", "description": "The human readable ID, also called eye readable ID. A system-assigned sequential alternate ID" diff --git a/src/test/java/org/folio/search/controller/SearchItemsConsortiumIT.java b/src/test/java/org/folio/search/controller/SearchItemsConsortiumIT.java new file mode 100644 index 000000000..a315e5024 --- /dev/null +++ b/src/test/java/org/folio/search/controller/SearchItemsConsortiumIT.java @@ -0,0 +1,111 @@ +package org.folio.search.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.folio.search.controller.SearchConsortiumController.REQUEST_NOT_ALLOWED_MSG; +import static org.folio.search.model.Pair.pair; +import static org.folio.search.sample.SampleInstances.getSemanticWeb; +import static org.folio.search.sample.SampleInstances.getSemanticWebId; +import static org.folio.search.support.base.ApiEndpoints.consortiumItemsSearchPath; +import static org.folio.search.utils.TestConstants.CENTRAL_TENANT_ID; +import static org.folio.search.utils.TestConstants.MEMBER_TENANT_ID; +import static org.folio.search.utils.TestUtils.parseResponse; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.folio.search.domain.dto.ConsortiumItem; +import org.folio.search.domain.dto.ConsortiumItemCollection; +import org.folio.search.model.Pair; +import org.folio.search.support.base.BaseConsortiumIntegrationTest; +import org.folio.spring.testing.type.IntegrationTest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +@IntegrationTest +class SearchItemsConsortiumIT extends BaseConsortiumIntegrationTest { + + @BeforeAll + static void prepare() { + setUpTenant(CENTRAL_TENANT_ID); + setUpTenant(MEMBER_TENANT_ID, getSemanticWeb()); + } + + @AfterAll + static void cleanUp() { + removeTenant(); + } + + @Test + void doGetConsortiumItems_returns200AndRecords() { + List> queryParams = List.of( + pair("instanceId", getSemanticWebId()) + ); + var result = doGet(consortiumItemsSearchPath(queryParams), CENTRAL_TENANT_ID); + var actual = parseResponse(result, ConsortiumItemCollection.class); + + assertThat(actual.getTotalRecords()).isEqualTo(3); + assertThat(actual.getItems()).containsExactlyInAnyOrder(getExpectedItems()); + } + + @Test + void doGetConsortiumItems_returns200AndRecords_withAllQueryParams() { + List> queryParams = List.of( + pair("instanceId", getSemanticWebId()), + pair("tenantId", MEMBER_TENANT_ID), + pair("holdingsRecordId", "e3ff6133-b9a2-4d4c-a1c9-dc1867d4df19"), + pair("limit", "1"), + pair("offset", "1"), + pair("sortBy", "barcode"), + pair("sortOrder", "desc") + ); + var result = doGet(consortiumItemsSearchPath(queryParams), CENTRAL_TENANT_ID); + var actual = parseResponse(result, ConsortiumItemCollection.class); + + assertThat(actual.getTotalRecords()).isEqualTo(1); + assertThat(actual.getItems()) + .satisfiesExactly(input -> assertEquals("10101", input.getBarcode())); + } + + @Test + void tryGetConsortiumItems_returns400_whenRequestedForNotCentralTenant() throws Exception { + tryGet(consortiumItemsSearchPath()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors[0].message", is(REQUEST_NOT_ALLOWED_MSG))) + .andExpect(jsonPath("$.errors[0].type", is("RequestValidationException"))) + .andExpect(jsonPath("$.errors[0].code", is("validation_error"))) + .andExpect(jsonPath("$.errors[0].parameters[0].key", is("x-okapi-tenant"))) + .andExpect(jsonPath("$.errors[0].parameters[0].value", is(MEMBER_TENANT_ID))); + } + + @Test + void tryGetConsortiumItems_returns400_whenInstanceIdIsNotSpecified() throws Exception { + List> queryParams = List.of( + pair("limit", "1"), + pair("offset", "1"), + pair("sortBy", "barcode"), + pair("sortOrder", "desc") + ); + tryGet(consortiumItemsSearchPath(queryParams), CENTRAL_TENANT_ID) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors[0].message", is("instanceId filter is required"))) + .andExpect(jsonPath("$.errors[0].type", is("RequestValidationException"))) + .andExpect(jsonPath("$.errors[0].code", is("validation_error"))); + } + + private ConsortiumItem[] getExpectedItems() { + var instance = getSemanticWeb(); + return instance.getItems().stream() + .map(item -> new ConsortiumItem() + .id(item.getId()) + .hrid(item.getHrid()) + .tenantId(MEMBER_TENANT_ID) + .instanceId(instance.getId()) + .holdingsRecordId(item.getHoldingsRecordId()) + .barcode(item.getBarcode()) + ) + .toArray(ConsortiumItem[]::new); + } +} diff --git a/src/test/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilderTest.java b/src/test/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilderTest.java index 82f41bd64..8c7c96ff6 100644 --- a/src/test/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilderTest.java +++ b/src/test/java/org/folio/search/service/consortium/ConsortiumSearchQueryBuilderTest.java @@ -54,7 +54,7 @@ void testBuildSelectQuery_forHoldingsResource_whenAllParametersDefined() { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "ORDER BY id desc LIMIT 100 OFFSET 10", actual); } @@ -87,7 +87,7 @@ void testBuildSelectQuery_forHoldingsResource_whenSortByEmpty(String sortBy) { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "LIMIT 100 OFFSET 10", actual); } @@ -102,7 +102,7 @@ void testBuildSelectQuery_forHoldingsResource_whenSortOrderEmpty() { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "ORDER BY id LIMIT 100 OFFSET 10", actual); } @@ -117,7 +117,7 @@ void testBuildSelectQuery_forHoldingsResource_whenLimitEmpty() { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "ORDER BY id desc OFFSET 10", actual); } @@ -132,14 +132,41 @@ void testBuildSelectQuery_forHoldingsResource_whenOffsetEmpty() { + "i.holdings ->> 'permanentLocationId' AS permanentLocationId, " + "i.holdings ->> 'discoverySuppress' AS discoverySuppress " + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'holdings') as holdings " - + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ?) i " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + "ORDER BY id desc LIMIT 100", actual); } + @Test + void testBuildSelectQuery_forItemResource_whenAllParametersDefined() { + var searchContext = new SearchContextMockBuilder().forItem().build(); + + var actual = new ConsortiumSearchQueryBuilder(searchContext).buildSelectQuery(executionContext); + assertEquals("SELECT i.instance_id as instanceId, i.tenant_id as tenantId, " + + "i.items ->> 'id' AS id, i.items ->> 'hrid' AS hrid, " + + "i.items ->> 'holdingsRecordId' AS holdingsRecordId, i.items ->> 'barcode' AS barcode " + + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'items') as items " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + + "WHERE i.items ->> 'holdingsRecordId' = ? ORDER BY id desc LIMIT 100 OFFSET 10", actual); + } + + @Test + void testBuildSelectQuery_forItemResource_whenJsonbFilterIsEmpty() { + var searchContext = new SearchContextMockBuilder().forItem().withHoldingsRecordId(null).build(); + + var actual = new ConsortiumSearchQueryBuilder(searchContext).buildSelectQuery(executionContext); + assertEquals("SELECT i.instance_id as instanceId, i.tenant_id as tenantId, " + + "i.items ->> 'id' AS id, i.items ->> 'hrid' AS hrid, " + + "i.items ->> 'holdingsRecordId' AS holdingsRecordId, i.items ->> 'barcode' AS barcode " + + "FROM (SELECT instance_id, tenant_id, json_array_elements(json -> 'items') as items " + + "FROM schema.consortium_instance WHERE instance_id = ? AND tenant_id = ? ) i " + + "ORDER BY id desc LIMIT 100 OFFSET 10", actual); + } + private static final class SearchContextMockBuilder { private ResourceType resourceType; private String instanceId = "inst123"; private String tenantId = "tenant"; + private String holdingsRecordId = "holding123"; private String sortBy = "id"; private SortOrder sortOrder = SortOrder.DESC; private Integer limit = 100; @@ -150,11 +177,21 @@ SearchContextMockBuilder forHoldings() { return this; } + SearchContextMockBuilder forItem() { + this.resourceType = ResourceType.ITEM; + return this; + } + SearchContextMockBuilder withInstanceId(String instanceId) { this.instanceId = instanceId; return this; } + SearchContextMockBuilder withHoldingsRecordId(String holdingsRecordId) { + this.holdingsRecordId = holdingsRecordId; + return this; + } + SearchContextMockBuilder withTenantId(String tenantId) { this.tenantId = tenantId; return this; @@ -200,6 +237,9 @@ private ArrayList> getFilters() { if (StringUtils.isNotBlank(tenantId)) { filters.add(Pair.pair("tenantId", tenantId)); } + if (StringUtils.isNotBlank(holdingsRecordId) && resourceType == ResourceType.ITEM) { + filters.add(Pair.pair("holdingsRecordId", tenantId)); + } return filters; } } diff --git a/src/test/java/org/folio/search/support/base/ApiEndpoints.java b/src/test/java/org/folio/search/support/base/ApiEndpoints.java index e9136460b..b3206ed62 100644 --- a/src/test/java/org/folio/search/support/base/ApiEndpoints.java +++ b/src/test/java/org/folio/search/support/base/ApiEndpoints.java @@ -22,12 +22,16 @@ public static String consortiumHoldingsSearchPath() { } public static String consortiumHoldingsSearchPath(List> queryParams) { - var queryParamString = queryParams.stream() - .map(param -> param.getFirst() + "=" + param.getSecond()) - .collect(Collectors.joining("&")); - return consortiumHoldingsSearchPath() + "?" + queryParamString; + return addQueryParams(consortiumHoldingsSearchPath(), queryParams); } + public static String consortiumItemsSearchPath() { + return "/search/consortium/items"; + } + + public static String consortiumItemsSearchPath(List> queryParams) { + return addQueryParams(consortiumItemsSearchPath(), queryParams); + } public static String authoritySearchPath() { return "/search/authorities"; @@ -117,4 +121,11 @@ public static String updateIndexSettingsPath() { public static String allRecordsSortedBy(String sort, CqlSort order) { return String.format("cql.allRecords=1 sortBy %s/sort.%s", sort, order); } + + private static String addQueryParams(String path, List> queryParams) { + var queryParamString = queryParams.stream() + .map(param -> param.getFirst() + "=" + param.getSecond()) + .collect(Collectors.joining("&")); + return path + "?" + queryParamString; + } } diff --git a/src/test/resources/samples/semantic-web-primer/items.json b/src/test/resources/samples/semantic-web-primer/items.json index 92b3519dc..5ef89aa37 100644 --- a/src/test/resources/samples/semantic-web-primer/items.json +++ b/src/test/resources/samples/semantic-web-primer/items.json @@ -133,5 +133,41 @@ "createdDate": "2020-12-08T15:47:22.827+00:00", "updatedDate": "2020-12-08T15:47:22.827+00:00" } + }, + { + "id": "49347f02-69ac-4c12-b6f2-2afb6059c94b", + "hrid": "it00000000001", + "holdingsRecordId": "9550c935-401a-4a85-875e-4d1fe7678870", + "formerIds": [], + "barcode": "12345", + "effectiveShelvingOrder": "3332 12 suff 2", + "effectiveCallNumberComponents": { + "callNumber": "332. 2", + "prefix": "prefix 2", + "suffix": "suff 2", + "typeId": "03dd64d0-5626-4ecd-8ece-4531e0069f35" + }, + "yearCaption": [], + "administrativeNotes": [], + "notes": [], + "circulationNotes": [], + "status": { + "name": "Available", + "date": "2024-03-14T13:05:09.407+00:00" + }, + "materialTypeId": "5ee11d91-f7e8-481d-b079-65d708582ccc", + "permanentLoanTypeId": "e8b311a6-3b21-43f2-a269-dd9310cb2d0e", + "effectiveLocationId": "cdd60388-0c75-4969-b3c5-2d04621ed26f", + "electronicAccess": [], + "statisticalCodeIds": [], + "tags": { + "tagList": [] + }, + "metadata": { + "createdDate": "2024-03-14T13:05:09.406+00:00", + "createdByUserId": "93fc1367-7321-4836-b2a7-cbc7ffe8c20e", + "updatedDate": "2024-03-14T13:05:09.406+00:00", + "updatedByUserId": "93fc1367-7321-4836-b2a7-cbc7ffe8c20e" + } } ]