Skip to content

Commit

Permalink
feat(search-instances): implement endpoint for consolidate items acce…
Browse files Browse the repository at this point in the history
…ss in consortium (#539)

Closes: MSEARCH-693
  • Loading branch information
psmagin authored Mar 15, 2024
1 parent f2417de commit 1a65a74
Show file tree
Hide file tree
Showing 17 changed files with 547 additions and 140 deletions.
4 changes: 2 additions & 2 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
* Synchronize browse config with classification types changes ([MSEARCH-683](https://issues.folio.org/browse/MSEARCH-683))
* 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))
* Add ability to case insensitive search ISSNs with trailing roman numerals ([MSEARCH-672](https://folio-org.atlassian.net/browse/MSEARCH-672))
* Remove call number format validation for shelving order generation ([MSEARCH-689](https://folio-org.atlassian.net/browse/MSEARCH-689))
* 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))
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@
"modulePermissions": [
"user-tenants.collection.get"
]
},
{
"methods": [
"GET"
],
"pathPattern": "/search/consortium/items",
"permissionsRequired": [
"consortium-search.items.collection.get"
],
"modulePermissions": [
"user-tenants.collection.get"
]
}
]
},
Expand Down Expand Up @@ -612,6 +624,11 @@
"permissionName": "consortium-search.holdings.collection.get",
"displayName": "Consortium Search - fetch holdings records",
"description": "Returns holdings records in consortium"
},
{
"permissionName": "consortium-search.items.collection.get",
"displayName": "Consortium Search - fetch items records",
"description": "Returns items records in consortium"
}
],
"launchDescriptor": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +45,24 @@ public ResponseEntity<ConsortiumHoldingCollection> getConsortiumHoldings(String
return ResponseEntity.ok(instanceService.fetchHoldings(context));
}

@Override
public ResponseEntity<ConsortiumItemCollection> 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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResourceType, List<String>> ALLOWED_SORT_FIELDS = Map.of(
ResourceType.HOLDINGS, List.of("id", "hrid", "tenantId", "instanceId",
"callNumberPrefix", "callNumber", "copyNumber", "permanentLocationId")
);

private static final Map<ResourceType, String> 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;
Expand All @@ -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);
}
Expand All @@ -54,7 +59,7 @@ public static ConsortiumSearchContextBuilder builderFor(ResourceType resourceTyp

public static class ConsortiumSearchContextBuilder {
private final ResourceType resourceType;
private List<Pair<String, String>> filters = new ArrayList<>();
private final List<Pair<String, String>> filters = new ArrayList<>();
private Integer limit;
private Integer offset;
private String sortBy;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public enum ResourceType {

INSTANCE("instance"),
HOLDINGS("holdings"),
ITEM("item"),
AUTHORITY("authority"),
CLASSIFICATION_TYPE("classification-type");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92,6 +93,19 @@ public List<ConsortiumHolding> fetchHoldings(ConsortiumSearchQueryBuilder search
);
}

public List<ConsortiumItem> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -155,6 +157,11 @@ public ConsortiumHoldingCollection fetchHoldings(ConsortiumSearchContext context
return new ConsortiumHoldingCollection().holdings(holdingList).totalRecords(holdingList.size());
}

public ConsortiumItemCollection fetchItems(ConsortiumSearchContext context) {
List<ConsortiumItem> itemList = repository.fetchItems(new ConsortiumSearchQueryBuilder(context));
return new ConsortiumItemCollection().items(itemList).totalRecords(itemList.size());
}

@SuppressWarnings("unchecked")
private void addListItems(List<Map<String, Object>> mergedList, Map<String, Object> instanceMap, String key) {
var items = instanceMap.get(key);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,40 +24,59 @@ public class ConsortiumSearchQueryBuilder {
static final String CONSORTIUM_INSTANCE_TABLE_NAME = "consortium_instance";
public static final Map<ResourceType, String> 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<ResourceType, List<String>> 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<ResourceType, Map<String, String>> 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<ResourceType, List<String>> RESOURCE_JSONB_FILTERS = Map.of(
ResourceType.ITEM, List.of("holdingsRecordId")
);

private static final Map<ResourceType, String> RESOURCE_COLLECTION_NAME = Map.of(
ResourceType.HOLDINGS, "holdings",
ResourceType.ITEM, "items"
);
private final ConsortiumSearchContext searchContext;
private final ResourceType resourceType;
private final List<Pair<String, String>> filters;
private final List<Pair<String, String>> 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();
return StringUtils.normalizeSpace(query);
}

public Object[] getQueryArguments() {
return filters.stream()
return Stream.concat(filters.stream(), jsonbFilters.stream())
.map(Pair::getSecond)
.toArray();
}
Expand Down Expand Up @@ -95,25 +118,38 @@ private String getSelectors(String source, List<String> sourceFields) {
.collect(Collectors.joining(", ")), ' ');
}

private String getWhereClause(List<Pair<String, String>> filters) {
private String getWhereClause(List<Pair<String, String>> 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<Pair<String, String>> prepareFilters(ResourceType resourceType) {
private List<Pair<String, String>> prepareFilters(ResourceType resourceType,
List<String> includeFilters, Collection<String> excludeFilters) {
var mappedFilterNames = RESOURCE_FILTER_DATABASE_NAME.get(resourceType);
return searchContext.getFilters().stream()
.map(filter -> {
if (mappedFilterNames.containsKey(filter.getFirst())) {
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();
}

}
Expand Down
Loading

0 comments on commit 1a65a74

Please sign in to comment.