Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MODINVSTOR-1343: Sort holdings by location name in instanceId query #1128

Merged
merged 4 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

### Bug fixes
* Add item barcode right truncation search index ([MODINVSTOR-1292](https://folio-org.atlassian.net/browse/MODINVSTOR-1292))
* Sort holdings by location name in instanceId query ([MODINVSTOR-1343](https://folio-org.atlassian.net/browse/MODINVSTOR-1343))

### Tech Dept
* Description ([ISSUE](https://folio-org.atlassian.net/browse/ISSUE))
Expand Down
9 changes: 7 additions & 2 deletions ramls/holdings-storage.raml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ resourceTypes:
exampleItem: !include examples/holdings-storage/holdingsRecord_get.json
get:
is: [pageable,
searchable: {description: "by instance ID (using CQL)",
example: "instanceId=\"2b94c631-fca9-4892-a730-03ee529ffe2a\""},
searchable: {
description:
"Sorting by a foreign table field is not supported; the only exception is a query of this form:
'instanceId==\"<UUID>\" sortBy [callNumberPrefix|callNumber|callNumberSuffix|effectiveLocation.name]+'
quoting the UUID is optional.",
example: "instanceId==\"2b94c631-fca9-4892-a730-03ee529ffe2a\""
},
]
post:
is: [validate]
Expand Down
50 changes: 50 additions & 0 deletions src/main/java/org/folio/persist/HoldingsRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import static org.folio.rest.impl.HoldingsStorageApi.HOLDINGS_RECORD_TABLE;
import static org.folio.rest.persist.PgUtil.postgresClient;
import static org.folio.services.location.LocationService.LOCATION_TABLE;

import io.vertx.core.Context;
import io.vertx.core.Future;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.RowSet;
import io.vertx.sqlclient.Tuple;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
Expand All @@ -19,6 +21,54 @@ public HoldingsRepository(Context context, Map<String, String> okapiHeaders) {
super(postgresClient(context, okapiHeaders), HOLDINGS_RECORD_TABLE, HoldingsRecord.class);
}

/**
* Produce a single row where the {@code holdings} column is a text field
* containing a JSON array with all holdings records; the
* {@code total_records} column is the exact totalRecords count.
*/
public Future<Row> getByInstanceId(String instanceId, String[] sortBys, int offset, int limit) {
var orderBy = new StringBuilder();
for (var sortBy : sortBys) {
if (sortBy.isEmpty()) {
continue;
}
var s = switch (sortBy) {
case "effectiveLocation.name" -> "name";
case "callNumberPrefix",
"callNumber",
"callNumberSuffix" -> "jsonb->>'" + sortBy + "'";
default -> null;
};
if (s == null) {
return Future.failedFuture(new IllegalArgumentException("sortBy: " + sortBy));
}
if (!orderBy.isEmpty()) {
orderBy.append(", ");
}
orderBy.append(s);
}
var sql = "WITH data AS NOT MATERIALIZED ("
+ " SELECT h.jsonb AS jsonb, l.jsonb->>'name' AS name"
+ " FROM " + HOLDINGS_RECORD_TABLE + " h"
+ " LEFT JOIN " + LOCATION_TABLE + " l"
+ " ON h.effectiveLocationId = l.id"
+ " WHERE h.instanceId=$1"
+ " )"
+ " SELECT json_array("
+ " SELECT jsonb"
+ " FROM data"
+ " ORDER BY " + orderBy
+ " OFFSET $2"
+ " LIMIT $3"
+ " )::text AS holdings, ("
+ " SELECT COUNT(*) AS total_records"
+ " FROM " + HOLDINGS_RECORD_TABLE
+ " WHERE instanceId=$1"
+ ")";
return postgresClient.withReadConn(conn -> conn.execute(sql, Tuple.of(instanceId, offset, limit)))
.map(rowSet -> rowSet.iterator().next());
}

/**
* Delete by CQL. For each deleted record return a {@link Row} with the instance id String
* and with the holdings' jsonb String.
Expand Down
14 changes: 11 additions & 3 deletions src/main/java/org/folio/rest/impl/HoldingsStorageApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,17 @@ public void getHoldingsStorageHoldings(String totalRecords, int offset, int limi
RoutingContext routingContext, Map<String, String> okapiHeaders,
Handler<AsyncResult<Response>> asyncResultHandler,
Context vertxContext) {

PgUtil.streamGet(HOLDINGS_RECORD_TABLE, HoldingsRecordView.class, query, offset,
limit, null, "holdingsRecords", routingContext, okapiHeaders, vertxContext);
new HoldingsService(vertxContext, okapiHeaders)
.getByInstanceId(offset, limit, query)
.onSuccess(response -> {
if (response != null) {
asyncResultHandler.handle(succeededFuture(response));
return;
}
PgUtil.streamGet(HOLDINGS_RECORD_TABLE, HoldingsRecordView.class, query, offset,
limit, null, "holdingsRecords", routingContext, okapiHeaders, vertxContext);
})
.onFailure(handleFailure(asyncResultHandler));
}

@Validate
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/org/folio/services/holding/HoldingsService.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.Logger;
Expand Down Expand Up @@ -55,6 +57,14 @@
public class HoldingsService {
private static final Logger log = getLogger(HoldingsService.class);
private static final ObjectMapper OBJECT_MAPPER = ObjectMapperTool.getMapper();
private static final Pattern INSTANCEID_PATTERN = Pattern.compile(
"^ *instanceId *== *\"?("
// UUID
+ "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
+ ")\"?"
+ " +sortBy"
// allow any sub-set of the fields and allow the fields in any order
+ "(( +(effectiveLocation\\.name|callNumberPrefix|callNumber|callNumberSuffix))+) *$");

private final Context vertxContext;
private final Map<String, String> okapiHeaders;
Expand Down Expand Up @@ -82,6 +92,35 @@ public HoldingsService(Context context, Map<String, String> okapiHeaders) {
context.get(ConsortiumDataCache.class.getName()));
}

/**
* Returns Response if the query is supported by instanceId query, null otherwise.
*
* <p>
*/
public Future<Response> getByInstanceId(int offset, int limit, String query) {
if (query == null) {
return Future.succeededFuture();
}
var matcher = INSTANCEID_PATTERN.matcher(query);
if (!matcher.find()) {
return Future.succeededFuture();
}
var instanceId = matcher.group(1);
var sortBy = matcher.group(2).split(" +");
return holdingsRepository.getByInstanceId(instanceId, sortBy, offset, limit)
.map(row -> {
var json = "{ \"holdingsRecords\": " + row.getString("holdings") + ",\n"
+ " \"totalRecords\": " + row.getLong("total_records") + ",\n"
+ " \"resultInfo\": { \n"
+ " \"totalRecords\": " + row.getLong("total_records") + ",\n"
+ " \"totalRecordsEstimated\": false\n"
+ " }\n"
+ "}";
return Response.ok(json, MediaType.APPLICATION_JSON).build();
})
.onFailure(e -> log.error("getByInstanceId:: {}", e.getMessage(), e));
}

/**
* Deletes all holdings but sends only a single domain event (Kafka) message "all records removed",
* this is much faster than sending one message for each deleted holding.
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/templates/db_scripts/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,7 @@
{
"fieldName": "effectiveLocationId",
"targetTable": "location",
"targetTableAlias": "effectiveLocation",
"tOps": "ADD"
},
{
Expand Down
24 changes: 24 additions & 0 deletions src/test/java/org/folio/persist/HoldingsRepositoryTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.folio.persist;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;

import io.vertx.core.Vertx;
import java.util.Map;
import org.junit.jupiter.api.Test;

class HoldingsRepositoryTest {

@Test
void invalidSortBy() {
String[] sortBys = {"foo"};
var context = Vertx.vertx().getOrCreateContext();
var headers = Map.of("X-Okapi-Tenant", "diku");
var holdingsRepository = new HoldingsRepository(context, headers);
var future = holdingsRepository.getByInstanceId(null, sortBys, 0, 0);
assertThat(future.cause(), is(instanceOf(IllegalArgumentException.class)));
assertThat(future.cause().getMessage(), is("sortBy: foo"));
}

}
59 changes: 58 additions & 1 deletion src/test/java/org/folio/rest/api/HoldingsStorageTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,13 @@
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import junitparams.JUnitParamsRunner;
import junitparams.Parameters;
import kotlin.jvm.functions.Function4;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
Expand All @@ -86,6 +88,7 @@
import org.folio.rest.tools.utils.OptimisticLockingUtil;
import org.folio.services.consortium.entities.SharingInstance;
import org.folio.services.consortium.entities.SharingStatus;
import org.folio.util.PercentCodec;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
Expand Down Expand Up @@ -699,6 +702,54 @@ public void canPageAllHoldings()
assertThat(secondPage.getInteger("totalRecords"), is(5));
}

@Test
@SneakyThrows
public void canGetByInstanceId() {
UUID instanceId = UUID.randomUUID();
instancesClient.create(nod(instanceId));
Function4<UUID, String, String, String, String> createHolding =
(location, prefix, callNumber, suffix) -> createHolding(new HoldingRequestBuilder()
.forInstance(instanceId)
.withSource(getPreparedHoldingSourceId())
.withPermanentLocation(location)
.withCallNumberPrefix(prefix)
.withCallNumber(callNumber)
.withCallNumberSuffix(suffix))
.getId().toString();
final var h7 = createHolding.invoke(SECOND_FLOOR_LOCATION_ID, "b", "k", "p");
final var h6 = createHolding.invoke(MAIN_LIBRARY_LOCATION_ID, "c", "j", "q");
final var h5 = createHolding.invoke(MAIN_LIBRARY_LOCATION_ID, "b", "k", "q");
final var h4 = createHolding.invoke(MAIN_LIBRARY_LOCATION_ID, "a", "l", "r");
final var h3 = createHolding.invoke(MAIN_LIBRARY_LOCATION_ID, "a", "k", "o");
final var h2 = createHolding.invoke(MAIN_LIBRARY_LOCATION_ID, "a", "j", "q");
final var h1 = createHolding.invoke(MAIN_LIBRARY_LOCATION_ID, "a", "j", "p");
final var h0 = createHolding.invoke(ANNEX_LIBRARY_LOCATION_ID, "b", "k", "q");
Function<String, List<String>> getHoldingsIds = query -> holdingsClient.getByQuery(query).stream()
.map(holding -> holding.getString("id")).toList();

var query = "instanceId==" + instanceId
+ " sortBy effectiveLocation.name callNumberPrefix callNumber callNumberSuffix";
query = "?query=" + PercentCodec.encode(query);
var ids = getHoldingsIds.apply(query);
assertThat(ids, is(List.of(h0, h1, h2, h3, h4, h5, h6, h7)));

ids = getHoldingsIds.apply(query + "&offset=2&limit=4");
assertThat(ids, is(List.of(h2, h3, h4, h5)));

query = "instanceId==" + instanceId + " sortBy callNumberSuffix callNumber callNumberPrefix effectiveLocation.name";
query = "?query=" + PercentCodec.encode(query);
ids = getHoldingsIds.apply(query);
// h3 M a k o
// h1 M a j p
// h7 S b k p
// h2 M a j q
// h6 M c j q
// h0 A b k q
// h5 M b k q
// h4 M a l r
assertThat(ids, is(List.of(h3, h1, h7, h2, h6, h0, h5, h4)));
}

@Test
public void canDeleteAllHoldings() {
UUID firstInstanceId = UUID.randomUUID();
Expand Down Expand Up @@ -1059,7 +1110,7 @@ public void updatingHoldingsUpdatesItemShelvingOrder(

UUID holdingId = UUID.randomUUID();

final JsonObject holding = holdingsClient.create(new HoldingRequestBuilder()
final JsonObject holding = createHolding(new HoldingRequestBuilder()
.withId(holdingId)
.forInstance(instanceId)
.withSource(getPreparedHoldingSourceId())
Expand Down Expand Up @@ -3362,6 +3413,12 @@ private void assertHridRange(Response response, String minHrid, String maxHrid)
is(both(greaterThanOrEqualTo(minHrid)).and(lessThanOrEqualTo(maxHrid))));
}

private IndividualResource createHolding(HoldingRequestBuilder holding) {
return holdingsClient.create(holding.create(),
TENANT_ID,
Map.of(X_OKAPI_URL, mockServer.baseUrl()));
}

private Response create(URL url, Object entity) throws InterruptedException, ExecutionException, TimeoutException {
CompletableFuture<Response> createCompleted = new CompletableFuture<>();

Expand Down
Loading