From 5b528d3ee8431d73bdf935b88dbac243a77e5424 Mon Sep 17 00:00:00 2001 From: Saba-Zedginidze-EPAM <148070844+Saba-Zedginidze-EPAM@users.noreply.github.com> Date: Mon, 23 Dec 2024 17:36:27 +0400 Subject: [PATCH] [MODORDSTOR-435] Introduce new batch update pieces endpoint (#463) * [MODORDSTOR-435] Add new endpoint * [MODORDSTOR-435] Leftover readme changes * [MODORDSTOR-435] Update acq-models pointer * [MODORDSTOR-435] Move piece post and put methods to service * [MODORDSTOR-435] Implement new endpoint * [MODORDSTOR-435] Add logger to APIs manually * [MODORDSTOR-435] Update unit tests * [MODORDSTOR-435] Fix typo --- README.md | 23 ++++ descriptors/ModuleDescriptor-template.json | 13 +- ramls/acq-models | 2 +- ramls/pieces-batch.raml | 45 ++++++ .../folio/dao/PieceClaimingRepository.java | 4 +- .../HoldingCreateAsyncRecordHandler.java | 2 +- .../java/org/folio/rest/impl/PiecesAPI.java | 65 +++++---- .../org/folio/rest/impl/PoLineBatchAPI.java | 14 +- .../java/org/folio/rest/impl/TitlesAPI.java | 6 +- .../folio/services/lines/PoLinesService.java | 7 +- .../folio/services/piece/PieceService.java | 50 +++++-- src/main/java/org/folio/util/DbUtils.java | 2 +- src/main/java/org/folio/util/HelperUtils.java | 6 + .../java/org/folio/util/MetadataUtils.java | 28 ++++ src/test/java/org/folio/StorageTestSuite.java | 3 + .../org/folio/rest/impl/PiecesAPITest.java | 129 ++++++++++++++++-- .../java/org/folio/rest/impl/TestBase.java | 8 ++ .../services/piece/PieceServiceTest.java | 20 +++ 18 files changed, 352 insertions(+), 75 deletions(-) create mode 100644 ramls/pieces-batch.raml create mode 100644 src/main/java/org/folio/util/MetadataUtils.java diff --git a/README.md b/README.md index 16a4dec4..b45024ce 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,29 @@ In order to support search and filtering for the APIs /orders-storage/purchase-o ## Additional information +### Kafka domain event pattern +The pattern means that every time when a domain entity is created/updated/deleted a message is posted to kafka topic. +Currently, domain events are supported for orders, order lines and pieces The events are posted into the following topics: + +- `ACQ_ORDER_CHANGED` - for orders +- `ACQ_ORDER_LINE_CHANGED` - for order lines +- `ACQ_PIECE_CHANGED` - for pieces + +The event payload has the following structure: +```json5 +{ + "id": "12bb13f6-d0fa-41b5-b0ad-d6561975121b", + "action": "CREATED|UPDATED|DELETED", + "userId": "1d4f3f6-d0fa-41b5-b0ad-d6561975121b", + "eventDate": "2024-11-14T10:00:00.000+0000", + "actionDate": "2024-11-14T10:00:00.000+0000", + "entitySnapshot": { } // entity being either: order, orderLine, piece +} +``` + +Default value for all partitions is 1. +Kafka partition key for all the events is entity id. + ### Issue tracker See project [MODORDSTOR](https://issues.folio.org/browse/MODORDSTOR) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index fe73ab2c..d54ed565 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -61,6 +61,11 @@ "methods": ["DELETE"], "pathPattern": "/orders-storage/pieces/{id}", "permissionsRequired": ["orders-storage.pieces.item.delete"] + }, + { + "methods": ["PUT"], + "pathPattern": "/orders-storage/pieces-batch", + "permissionsRequired": ["orders-storage.pieces-batch.collection.put"] } ] }, @@ -780,6 +785,11 @@ "displayName" : "orders-storage.pieces-item delete", "description" : "Delete a piece" }, + { + "permissionName" : "orders-storage.pieces-batch.collection.put", + "displayName" : "Pieces batch update", + "description" : "Update a set of Pieces in a batch" + }, { "permissionName" : "orders-storage.pieces.all", "displayName" : "All orders-storage pieces perms", @@ -789,7 +799,8 @@ "orders-storage.pieces.item.post", "orders-storage.pieces.item.get", "orders-storage.pieces.item.put", - "orders-storage.pieces.item.delete" + "orders-storage.pieces.item.delete", + "orders-storage.pieces-batch.collection.put" ] }, { diff --git a/ramls/acq-models b/ramls/acq-models index c1f93170..a8f2fef0 160000 --- a/ramls/acq-models +++ b/ramls/acq-models @@ -1 +1 @@ -Subproject commit c1f931704fc6d2dedfc671e308b27f56499750a7 +Subproject commit a8f2fef0b41fbe99f917a39857821b93c417b1a9 diff --git a/ramls/pieces-batch.raml b/ramls/pieces-batch.raml new file mode 100644 index 00000000..b23a6dea --- /dev/null +++ b/ramls/pieces-batch.raml @@ -0,0 +1,45 @@ +#%RAML 1.0 +title: "mod-orders" +baseUri: http://github.com/folio-org/mod-orders-storage +version: v1.0 + +documentation: + - title: Pieces batch API + content: This module implements the Pieces batch processing interface. This API is intended for internal use only. + +types: + pieces-collection: !include acq-models/mod-orders-storage/schemas/piece_collection.json + UUID: + type: string + pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$ + +resourceTypes: + collection: !include raml-util/rtypes/collection.raml + collection-item: !include raml-util/rtypes/item-collection.raml +traits: + + +/orders-storage/pieces-batch: + displayName: Process list of Pieces in a batch + description: Process list of Pieces in a batch APIs + put: + description: "Update the list of Pieces in a batch" + body: + application/json: + type: pieces-collection + example: + strict: false + value: !include acq-models/mod-orders-storage/examples/piece_collection.sample + responses: + 204: + description: "Collection successfully updated" + 400: + description: "Bad request, e.g. malformed request body or query parameter. Details of the error (e.g. name of the parameter or line/character number with malformed data) provided in the response." + body: + text/plain: + example: "unable to update <> -- malformed JSON at 13:4" + 500: + description: "Internal server error, e.g. due to misconfiguration" + body: + text/plain: + example: "internal server error, contact administrator" diff --git a/src/main/java/org/folio/dao/PieceClaimingRepository.java b/src/main/java/org/folio/dao/PieceClaimingRepository.java index b94292cf..4413e7cf 100644 --- a/src/main/java/org/folio/dao/PieceClaimingRepository.java +++ b/src/main/java/org/folio/dao/PieceClaimingRepository.java @@ -11,8 +11,8 @@ import java.time.ZoneId; import static org.folio.dao.audit.AuditOutboxEventsLogRepository.OUTBOX_TABLE_NAME; -import static org.folio.rest.impl.PiecesAPI.PIECES_TABLE; -import static org.folio.rest.impl.TitlesAPI.TITLES_TABLE; +import static org.folio.models.TableNames.PIECES_TABLE; +import static org.folio.models.TableNames.TITLES_TABLE; import static org.folio.rest.persist.PostgresClient.convertToPsqlStandard; public class PieceClaimingRepository { diff --git a/src/main/java/org/folio/event/handler/HoldingCreateAsyncRecordHandler.java b/src/main/java/org/folio/event/handler/HoldingCreateAsyncRecordHandler.java index 78ce5a92..94aa3723 100644 --- a/src/main/java/org/folio/event/handler/HoldingCreateAsyncRecordHandler.java +++ b/src/main/java/org/folio/event/handler/HoldingCreateAsyncRecordHandler.java @@ -77,7 +77,7 @@ private Future processPoLinesUpdate(String holdingId, String permanentLoca } private Future processPiecesUpdate(String holdingId, String tenantIdFromEvent, - String centralTenantId, Map headers,Conn conn) { + String centralTenantId, Map headers, Conn conn) { return pieceService.getPiecesByHoldingId(holdingId, conn) .compose(pieces -> updatePieces(pieces, holdingId, tenantIdFromEvent, centralTenantId, conn)) .compose(pieces -> auditOutboxService.savePiecesOutboxLog(conn, pieces, PieceAuditEvent.Action.EDIT, headers)) diff --git a/src/main/java/org/folio/rest/impl/PiecesAPI.java b/src/main/java/org/folio/rest/impl/PiecesAPI.java index 61ce0962..ec1a24f3 100644 --- a/src/main/java/org/folio/rest/impl/PiecesAPI.java +++ b/src/main/java/org/folio/rest/impl/PiecesAPI.java @@ -1,47 +1,47 @@ package org.folio.rest.impl; -import java.util.Date; +import static org.folio.models.TableNames.PIECES_TABLE; +import static org.folio.util.HelperUtils.extractEntityFields; +import static org.folio.util.MetadataUtils.populateMetadata; + import java.util.Map; -import java.util.UUID; import javax.ws.rs.core.Response; -import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.folio.dao.PostgresClientFactory; import org.folio.event.service.AuditOutboxService; -import org.folio.models.TableNames; import org.folio.rest.annotations.Validate; import org.folio.rest.core.BaseApi; import org.folio.rest.jaxrs.model.Piece; import org.folio.rest.jaxrs.model.PieceAuditEvent; import org.folio.rest.jaxrs.model.PieceCollection; +import org.folio.rest.jaxrs.model.PiecesCollection; import org.folio.rest.jaxrs.resource.OrdersStoragePieces; -import org.folio.rest.persist.Conn; +import org.folio.rest.jaxrs.resource.OrdersStoragePiecesBatch; import org.folio.rest.persist.HelperUtils; import org.folio.rest.persist.PgUtil; import org.folio.rest.persist.PostgresClient; +import org.folio.rest.tools.utils.TenantTool; +import org.folio.services.piece.PieceService; import org.folio.spring.SpringContextUtil; -import org.folio.util.DbUtils; import org.springframework.beans.factory.annotation.Autowired; import io.vertx.core.AsyncResult; import io.vertx.core.Context; -import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; -import io.vertx.sqlclient.Row; -import io.vertx.sqlclient.RowSet; -public class PiecesAPI extends BaseApi implements OrdersStoragePieces { - private static final Logger log = LogManager.getLogger(); +public class PiecesAPI extends BaseApi implements OrdersStoragePieces, OrdersStoragePiecesBatch { - public static final String PIECES_TABLE = "pieces"; + private static final Logger log = LogManager.getLogger(); private final PostgresClient pgClient; + @Autowired + private PieceService pieceService; @Autowired private AuditOutboxService auditOutboxService; @Autowired @@ -66,7 +66,7 @@ public void getOrdersStoragePieces(String query, String totalRecords, int offset @Validate public void postOrdersStoragePieces(Piece entity, Map okapiHeaders, Handler> asyncResultHandler, Context vertxContext) { - pgClient.withTrans(conn -> createPiece(conn, entity) + pgClient.withTrans(conn -> pieceService.createPiece(conn, entity) .compose(ignore -> auditOutboxService.savePieceOutboxLog(conn, entity, PieceAuditEvent.Action.CREATE, okapiHeaders))) .onComplete(ar -> { if (ar.succeeded()) { @@ -80,18 +80,6 @@ public void postOrdersStoragePieces(Piece entity, Map okapiHeade }); } - private Future createPiece(Conn conn, Piece piece) { - piece.setStatusUpdatedDate(new Date()); - if (StringUtils.isBlank(piece.getId())) { - piece.setId(UUID.randomUUID().toString()); - } - log.debug("Creating new piece with id={}", piece.getId()); - - return conn.save(TableNames.PIECES_TABLE, piece.getId(), piece) - .onSuccess(rowSet -> log.info("Piece successfully created, id={}", piece.getId())) - .onFailure(e -> log.error("Create piece failed, id={}", piece.getId(), e)); - } - @Override @Validate public void getOrdersStoragePiecesById(String id, Map okapiHeaders, @@ -110,7 +98,7 @@ public void deleteOrdersStoragePiecesById(String id, Map okapiHe @Validate public void putOrdersStoragePiecesById(String id, Piece entity, Map okapiHeaders, Handler> asyncResultHandler, Context vertxContext) { - pgClient.withTrans(conn -> updatePiece(conn, entity, id) + pgClient.withTrans(conn -> pieceService.updatePiece(conn, entity, id) .compose(ignore -> auditOutboxService.savePieceOutboxLog(conn, entity, PieceAuditEvent.Action.EDIT, okapiHeaders))) .onComplete(ar -> { if (ar.succeeded()) { @@ -124,13 +112,24 @@ public void putOrdersStoragePiecesById(String id, Piece entity, Map> updatePiece(Conn conn, Piece piece, String id) { - log.debug("Updating piece with id={}", id); - - return conn.update(TableNames.PIECES_TABLE, piece, id) - .compose(DbUtils::failOnNoUpdateOrDelete) - .onSuccess(rowSet -> log.info("Piece successfully updated, id={}", id)) - .onFailure(e -> log.error("Update piece failed, id={}", id, e)); + @Override + public void putOrdersStoragePiecesBatch(PiecesCollection piecesCollection, Map okapiHeaders, + Handler> asyncResultHandler, Context vertxContext) { + var piecesIds = extractEntityFields(piecesCollection.getPieces(), Piece::getId); + var pieces = piecesCollection.getPieces().stream().map(piece -> populateMetadata(piece::getMetadata, piece::withMetadata, okapiHeaders)).toList(); + log.info("putOrdersStoragePiecesBatch:: Batch updating {} pieces: {}", piecesIds.size(), piecesIds); + pgClient.withTrans(conn -> pieceService.updatePieces(pieces, conn, TenantTool.tenantId(okapiHeaders)) + .compose(v -> auditOutboxService.savePiecesOutboxLog(conn, pieces, PieceAuditEvent.Action.EDIT, okapiHeaders))) + .onComplete(ar -> { + if (ar.succeeded()) { + log.info("putOrdersStoragePiecesBatch:: Successfully updated pieces: {}", piecesIds); + auditOutboxService.processOutboxEventLogs(okapiHeaders); + asyncResultHandler.handle(buildNoContentResponse()); + } else { + log.error("putOrdersStoragePiecesBatch:: Failed to update pieces: {}", piecesIds, ar.cause()); + asyncResultHandler.handle(buildErrorResponse(ar.cause())); + } + }); } @Override diff --git a/src/main/java/org/folio/rest/impl/PoLineBatchAPI.java b/src/main/java/org/folio/rest/impl/PoLineBatchAPI.java index 7ec6fd23..337268d0 100644 --- a/src/main/java/org/folio/rest/impl/PoLineBatchAPI.java +++ b/src/main/java/org/folio/rest/impl/PoLineBatchAPI.java @@ -1,8 +1,8 @@ package org.folio.rest.impl; -import java.util.List; +import static org.folio.util.HelperUtils.extractEntityFields; + import java.util.Map; -import java.util.stream.Collectors; import javax.ws.rs.core.Response; @@ -50,11 +50,12 @@ public void putOrdersStoragePoLinesBatch(PoLineCollection poLineCollection, Map< .compose(cf -> poLinesBatchService.poLinesBatchUpdate(poLineCollection.getPoLines(), pgClient, okapiHeaders)) .onComplete(ar -> { + var poLineIds = extractEntityFields(poLineCollection.getPoLines(), PoLine::getId); if (ar.failed()) { - log.error("putOrdersStoragePoLinesBatch:: failed, PO line ids: {} ", getPoLineIdsForLogMessage(poLineCollection.getPoLines()), ar.cause()); + log.error("putOrdersStoragePoLinesBatch:: Failed to update PoLines: {}", poLineIds, ar.cause()); asyncResultHandler.handle(buildErrorResponse(ar.cause())); } else { - log.info("putOrdersStoragePoLinesBatch:: completed, PO line ids: {} ", getPoLineIdsForLogMessage(poLineCollection.getPoLines())); + log.info("putOrdersStoragePoLinesBatch:: Successfully updated PoLines: {}", poLineIds); auditOutboxService.processOutboxEventLogs(okapiHeaders); asyncResultHandler.handle(buildNoContentResponse()); } @@ -66,9 +67,4 @@ protected String getEndpoint(Object entity) { return HelperUtils.getEndpoint(OrdersStoragePoLinesBatch.class); } - private String getPoLineIdsForLogMessage(List polines) { - return polines.stream() - .map(PoLine::getId) - .collect(Collectors.joining(", ")); - } } diff --git a/src/main/java/org/folio/rest/impl/TitlesAPI.java b/src/main/java/org/folio/rest/impl/TitlesAPI.java index bd132591..60807500 100644 --- a/src/main/java/org/folio/rest/impl/TitlesAPI.java +++ b/src/main/java/org/folio/rest/impl/TitlesAPI.java @@ -1,11 +1,14 @@ package org.folio.rest.impl; +import static org.folio.models.TableNames.TITLES_TABLE; + import java.util.Map; import javax.ws.rs.core.Response; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.folio.dao.PostgresClientFactory; @@ -20,6 +23,7 @@ import io.vertx.core.AsyncResult; import io.vertx.core.Context; import io.vertx.core.Handler; + import org.folio.rest.persist.PostgresClient; import org.folio.services.title.TitleService; import org.folio.spring.SpringContextUtil; @@ -29,8 +33,6 @@ public class TitlesAPI extends BaseApi implements OrdersStorageTitles { private static final Logger log = LogManager.getLogger(); - public static final String TITLES_TABLE = "titles"; - private final PostgresClient pgClient; @Autowired diff --git a/src/main/java/org/folio/services/lines/PoLinesService.java b/src/main/java/org/folio/services/lines/PoLinesService.java index c7f53524..14fff810 100644 --- a/src/main/java/org/folio/services/lines/PoLinesService.java +++ b/src/main/java/org/folio/services/lines/PoLinesService.java @@ -2,11 +2,12 @@ import static java.util.stream.Collectors.toList; import static org.folio.dao.RepositoryConstants.MAX_IDS_FOR_GET_RQ_15; +import static org.folio.models.TableNames.PIECES_TABLE; import static org.folio.models.TableNames.PO_LINE_TABLE; import static org.folio.models.TableNames.PURCHASE_ORDER_TABLE; +import static org.folio.models.TableNames.TITLES_TABLE; import static org.folio.rest.core.ResponseUtil.handleFailure; import static org.folio.rest.core.ResponseUtil.httpHandleFailure; -import static org.folio.rest.impl.TitlesAPI.TITLES_TABLE; import static org.folio.rest.persist.HelperUtils.ID_FIELD_NAME; import static org.folio.rest.persist.HelperUtils.JSONB; import static org.folio.rest.persist.HelperUtils.getCriteriaByFieldNameAndValueNotJsonb; @@ -31,7 +32,6 @@ import org.folio.models.CriterionBuilder; import org.folio.okapi.common.GenericCompositeFuture; import org.folio.rest.core.models.RequestContext; -import org.folio.rest.impl.PiecesAPI; import org.folio.rest.jaxrs.model.OrderLineAuditEvent; import org.folio.rest.jaxrs.model.PoLine; import org.folio.rest.jaxrs.model.PurchaseOrder; @@ -57,7 +57,6 @@ public class PoLinesService { private static final String PO_LINE_ID = "poLineId"; - private static final String LOCATIONS_HOLDING_ID_FIELD = "location.holdingId"; private final PoLinesDAO poLinesDAO; private final AuditOutboxService auditOutboxService; @@ -443,7 +442,7 @@ private Future> deletePiecesByPOLineId(Tx tx, DBClient client Promise> promise = Promise.promise(); Criterion criterion = getCriterionByFieldNameAndValue(PO_LINE_ID, tx.getEntity()); - client.getPgClient().delete(tx.getConnection(), PiecesAPI.PIECES_TABLE, criterion, ar -> { + client.getPgClient().delete(tx.getConnection(), PIECES_TABLE, criterion, ar -> { if (ar.failed()) { log.error("Delete Pieces failed, criterion={}", criterion, ar.cause()); httpHandleFailure(promise, ar); diff --git a/src/main/java/org/folio/services/piece/PieceService.java b/src/main/java/org/folio/services/piece/PieceService.java index bd744cec..92623f0f 100644 --- a/src/main/java/org/folio/services/piece/PieceService.java +++ b/src/main/java/org/folio/services/piece/PieceService.java @@ -9,11 +9,16 @@ import static org.folio.util.DbUtils.getEntitiesByField; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Date; import java.util.List; import java.util.Objects; +import java.util.UUID; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.folio.models.TableNames; import org.folio.rest.jaxrs.model.Piece; import org.folio.rest.jaxrs.model.PoLine; import org.folio.rest.jaxrs.model.ReplaceInstanceRef; @@ -26,25 +31,29 @@ import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.json.JsonObject; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; import lombok.extern.log4j.Log4j2; import org.folio.util.SerializerUtil; @Log4j2 public class PieceService { - private static final String POLINE_ID_FIELD = "poLineId"; + private static final String PO_LINE_ID_FIELD = "poLineId"; private static final String ITEM_ID_FIELD = "itemId"; private static final String HOLDING_ID_FIELD = "holdingId"; private static final String PIECE_NOT_UPDATED = "Pieces with poLineId={} not presented, skipping the update"; + private static final String PIECES_BATCH_UPDATE_SQL = "UPDATE %s AS pieces SET jsonb = b.jsonb FROM (VALUES %s) AS b (id, jsonb) WHERE b.id::uuid = pieces.id RETURNING pieces.*;"; + public Future> getPiecesByPoLineId(String poLineId, DBClient client) { - var criterion = getCriteriaByFieldNameAndValueNotJsonb(POLINE_ID_FIELD, poLineId); - return getEntitiesByField(PIECES_TABLE, Piece.class, criterion, client); + var criterion = getCriteriaByFieldNameAndValueNotJsonb(PO_LINE_ID_FIELD, poLineId); + return client.getPgClient().withConn(conn -> getPiecesByField(criterion, conn)); } public Future> getPiecesByPoLineId(String poLineId, Conn conn) { - var criterion = getCriteriaByFieldNameAndValueNotJsonb(POLINE_ID_FIELD, poLineId); - return getEntitiesByField(PIECES_TABLE, Piece.class, criterion, conn); + var criterion = getCriteriaByFieldNameAndValueNotJsonb(PO_LINE_ID_FIELD, poLineId); + return getPiecesByField(criterion, conn); } public Future> getPiecesByItemId(String itemId, Conn conn) { @@ -57,10 +66,30 @@ public Future> getPiecesByHoldingId(String itemId, Conn conn) { return getPiecesByField(criterion, conn); } - public Future> getPiecesByField(Criterion criterion, Conn conn) { + private Future> getPiecesByField(Criterion criterion, Conn conn) { return getEntitiesByField(PIECES_TABLE, Piece.class, criterion, conn); } + public Future createPiece(Conn conn, Piece piece) { + piece.setStatusUpdatedDate(new Date()); + if (StringUtils.isBlank(piece.getId())) { + piece.setId(UUID.randomUUID().toString()); + } + log.debug("createPiece:: Creating new piece: '{}'", piece.getId()); + + return conn.save(TableNames.PIECES_TABLE, piece.getId(), piece) + .onSuccess(rowSet -> log.info("createPiece:: Piece successfully created: '{}'", piece.getId())) + .onFailure(e -> log.error("createPiece:: Create piece failed: '{}'", piece.getId(), e)); + } + + public Future> updatePiece(Conn conn, Piece piece, String id) { + log.debug("updatePiece:: Updating piece: '{}'", id); + return conn.update(TableNames.PIECES_TABLE, piece, id) + .compose(DbUtils::failOnNoUpdateOrDelete) + .onSuccess(rowSet -> log.info("updatePiece:: Piece successfully updated: '{}'", id)) + .onFailure(e -> log.error("updatePiece:: Update piece failed: '{}'", id, e)); + } + private Future> updatePieces(Tx poLineTx, List pieces, DBClient client) { Promise> promise = Promise.promise(); String poLineId = poLineTx.getEntity().getId(); @@ -84,6 +113,10 @@ private Future> updatePieces(Tx poLineTx, List pieces, } public Future> updatePieces(List pieces, Conn conn, String tenantId) { + if (CollectionUtils.isEmpty(pieces)) { + log.warn("updatePieces:: Pieces list is empty, skipping the update"); + return Future.succeededFuture(List.of()); + } String query = buildUpdatePieceBatchQuery(pieces, tenantId); return conn.execute(query) .map(rows -> DbUtils.getRowSetAsList(rows, Piece.class)) @@ -95,9 +128,7 @@ private String buildUpdatePieceBatchQuery(Collection pieces, String tenan List jsonPieces = pieces.stream() .map(SerializerUtil::toJson) .toList(); - return String.format( - "UPDATE %s AS pieces SET jsonb = b.jsonb FROM (VALUES %s) AS b (id, jsonb) WHERE b.id::uuid = pieces.id;", - getFullTableName(tenantId, PIECES_TABLE), getQueryValues(jsonPieces)); + return String.format(PIECES_BATCH_UPDATE_SQL, getFullTableName(tenantId, PIECES_TABLE), getQueryValues(jsonPieces)); } public Future> updatePieces(Tx poLineTx, ReplaceInstanceRef replaceInstanceRef, DBClient client) { @@ -134,4 +165,5 @@ private Future> updateHoldingForPieces(Tx poLineTx, List Future> handleEntities(Future> getEntities public static List getRowSetAsList(RowSet rowSet, Class entityClass) { return IteratorUtils.toList(rowSet.iterator()).stream() - .map(row -> row.toJson().mapTo(entityClass)) + .map(row -> row.getJsonObject("jsonb").mapTo(entityClass)) .toList(); } diff --git a/src/main/java/org/folio/util/HelperUtils.java b/src/main/java/org/folio/util/HelperUtils.java index b3d522c4..a54eb237 100644 --- a/src/main/java/org/folio/util/HelperUtils.java +++ b/src/main/java/org/folio/util/HelperUtils.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; import io.vertx.core.CompositeFuture; import io.vertx.core.Future; @@ -35,4 +37,8 @@ public static String convertFieldListToCqlQuery(Collection values, Strin String prefix = fieldName + (strictMatch ? "==(" : "=("); return StreamEx.of(values).joining(" or ", prefix, ")"); } + + public static List extractEntityFields(List entities, Function fieldExtractor) { + return entities.stream().map(fieldExtractor).collect(Collectors.toList()); + } } diff --git a/src/main/java/org/folio/util/MetadataUtils.java b/src/main/java/org/folio/util/MetadataUtils.java new file mode 100644 index 00000000..d3644404 --- /dev/null +++ b/src/main/java/org/folio/util/MetadataUtils.java @@ -0,0 +1,28 @@ +package org.folio.util; + +import java.util.Date; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.folio.okapi.common.XOkapiHeaders; +import org.folio.rest.jaxrs.model.Metadata; + +public class MetadataUtils { + + private MetadataUtils() { } + + public static T populateMetadata(Supplier metadataExtractor, Function metadataSetter, Map okapiHeaders) { + var userId = okapiHeaders.get(XOkapiHeaders.USER_ID); + var metadata = Optional.ofNullable(metadataExtractor.get()).orElseGet(Metadata::new); + metadata.setUpdatedDate(new Date()); + metadata.setUpdatedByUserId(userId); + if (metadata.getCreatedDate() == null && metadata.getCreatedByUserId() == null) { + metadata.setCreatedDate(metadata.getUpdatedDate()); + metadata.setCreatedByUserId(userId); + } + return metadataSetter.apply(metadata); + } + +} diff --git a/src/test/java/org/folio/StorageTestSuite.java b/src/test/java/org/folio/StorageTestSuite.java index 1d06568e..83cdfabf 100644 --- a/src/test/java/org/folio/StorageTestSuite.java +++ b/src/test/java/org/folio/StorageTestSuite.java @@ -42,6 +42,7 @@ import org.folio.rest.impl.EntititesCustomFieldsTest; import org.folio.rest.impl.HelperUtilsTest; import org.folio.rest.impl.OrdersAPITest; +import org.folio.rest.impl.PiecesAPITest; import org.folio.rest.impl.PoLineBatchAPITest; import org.folio.rest.impl.PoNumberTest; import org.folio.rest.impl.PurchaseOrderLineNumberTest; @@ -241,6 +242,8 @@ class EntitiesCrudTestNested extends EntitiesCrudTest {} @Nested class OrdersAPITestNested extends OrdersAPITest {} @Nested + class PiecesAPITestNested extends PiecesAPITest {} + @Nested class PoNumberTestNested extends PoNumberTest {} @Nested class PurchaseOrderLineApiTest extends PurchaseOrderLinesApiTest {} diff --git a/src/test/java/org/folio/rest/impl/PiecesAPITest.java b/src/test/java/org/folio/rest/impl/PiecesAPITest.java index 325d855b..5d3686c1 100644 --- a/src/test/java/org/folio/rest/impl/PiecesAPITest.java +++ b/src/test/java/org/folio/rest/impl/PiecesAPITest.java @@ -3,42 +3,66 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.net.MalformedURLException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.UUID; +import org.apache.commons.lang3.tuple.Pair; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.folio.CopilotGenerated; import org.folio.StorageTestSuite; import org.folio.event.AuditEventType; +import org.folio.rest.jaxrs.model.Piece; import org.folio.rest.jaxrs.model.PieceAuditEvent; +import org.folio.rest.jaxrs.model.PiecesCollection; import org.folio.rest.utils.TestData; import org.folio.rest.utils.TestEntities; -import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.restassured.http.Headers; import io.vertx.core.json.Json; import io.vertx.core.json.JsonObject; +@CopilotGenerated(partiallyGenerated = true) public class PiecesAPITest extends TestBase { private static final Logger log = LogManager.getLogger(); - @Test - void testPieceCreateUpdateEvents() throws MalformedURLException { - log.info("--- mod-orders-storage piece test: create / update event"); + private static final String PIECES_BATCH_ENDPOINT = "/orders-storage/pieces-batch"; - // given - String userId = UUID.randomUUID().toString(); - Headers headers = getDikuTenantHeaders(userId); - postData(TestEntities.PURCHASE_ORDER.getEndpoint(), getFile(TestData.PurchaseOrder.DEFAULT), headers).then().statusCode(201); - postData(TestEntities.PO_LINE.getEndpoint(), getFile(TestData.PoLine.DEFAULT), headers).then().statusCode(201); - postData(TestEntities.TITLES.getEndpoint(), getFile(TestData.Title.DEFAULT), headers).then().statusCode(201); + private String userId; + private String orderId; + private String poLineId; + private String titleId; + private Headers headers; + private List pieceIds; - callAuditOutboxApi(getDikuTenantHeaders(UUID.randomUUID().toString())); + @BeforeEach + void setUp() throws MalformedURLException { + userId = UUID.randomUUID().toString(); + orderId = UUID.randomUUID().toString(); + poLineId = UUID.randomUUID().toString(); + titleId = UUID.randomUUID().toString(); + headers = getDikuTenantHeaders(userId); + pieceIds = new ArrayList<>(); + prepareData(); + } + @AfterEach + void tearDown() throws MalformedURLException { + clearData(); + } + + @Test + void testPieceCreateUpdateEvents() throws MalformedURLException { + log.info("--- mod-orders-storage piece test: create / update event"); // when - JsonObject jsonPiece = new JsonObject(getFile(TestData.Piece.DEFAULT)); + pieceIds.add(UUID.randomUUID().toString()); + var jsonPiece = new JsonObject(getEntity(TestData.Piece.DEFAULT, pieceIds.get(0), "poLineId", poLineId, "titleId", titleId)); postData(TestEntities.PIECE.getEndpoint(), jsonPiece.toString(), headers) .then() .statusCode(201); @@ -56,4 +80,85 @@ void testPieceCreateUpdateEvents() throws MalformedURLException { checkPieceEventContent(events.get(1), PieceAuditEvent.Action.EDIT); } + @Test + void putOrdersStoragePiecesBatch_shouldUpdatePiecesSuccessfully() throws MalformedURLException { + log.info("--- mod-orders-storage piece test: batch update pieces"); + + // given + pieceIds.add(UUID.randomUUID().toString()); + pieceIds.add(UUID.randomUUID().toString()); + var jsonPiece1 = getEntity(TestData.Piece.DEFAULT, pieceIds.get(0), "poLineId", poLineId, "titleId", titleId); + var jsonPiece2 = getEntity(TestData.Piece.DEFAULT, pieceIds.get(1), "poLineId", poLineId, "titleId", titleId); + + var response = postData(TestEntities.PIECE.getEndpoint(), jsonPiece1, headers); + response.then().statusCode(201); + callAuditOutboxApi(headers); + var piece1 = response.as(Piece.class); + + response = postData(TestEntities.PIECE.getEndpoint(), jsonPiece2, headers); + response.then().statusCode(201); + callAuditOutboxApi(headers); + var piece2 = response.as(Piece.class); + + // when + var piecesCollection = new PiecesCollection().withPieces(List.of(piece1, piece2)); + putData(PIECES_BATCH_ENDPOINT, Json.encode(piecesCollection), headers).then().statusCode(204); + callAuditOutboxApi(headers); + + // then + List events = StorageTestSuite.checkKafkaEventSent(TENANT_NAME, AuditEventType.ACQ_PIECE_CHANGED.getTopicName(), 4, userId); + assertEquals(4, events.size()); + checkPieceEventContent(events.get(0), PieceAuditEvent.Action.CREATE); + checkPieceEventContent(events.get(1), PieceAuditEvent.Action.CREATE); + checkPieceEventContent(events.get(2), PieceAuditEvent.Action.EDIT); + checkPieceEventContent(events.get(3), PieceAuditEvent.Action.EDIT); + } + + @Test + void putOrdersStoragePiecesBatch_shouldHandleEmptyPieceList() throws MalformedURLException { + log.info("--- mod-orders-storage piece test: batch update with empty piece list"); + // when + PiecesCollection piecesCollection = new PiecesCollection().withPieces(List.of()); + + putData(PIECES_BATCH_ENDPOINT, Json.encode(piecesCollection), headers) + .then() + .statusCode(204); + callAuditOutboxApi(headers); + + // then + List events = StorageTestSuite.checkKafkaEventSent(TENANT_NAME, AuditEventType.ACQ_PIECE_CHANGED.getTopicName(), 0, userId); + assertEquals(0, events.size()); + } + + private void prepareData() throws MalformedURLException { + postData(TestEntities.PURCHASE_ORDER.getEndpoint(), getEntity(TestData.PurchaseOrder.DEFAULT, orderId, "poNumber", "12345"), headers) + .then().statusCode(201); + postData(TestEntities.PO_LINE.getEndpoint(), getEntity(TestData.PoLine.DEFAULT, poLineId, "purchaseOrderId", orderId, "poLineNumber", "12345-1"), headers) + .then().statusCode(201); + postData(TestEntities.TITLES.getEndpoint(), getEntity(TestData.Title.DEFAULT, titleId, "poLineId", poLineId), headers) + .then().statusCode(201); + + callAuditOutboxApi(headers); + } + + private void clearData() throws MalformedURLException { + var entities = new ArrayList<>(pieceIds.stream().map(id -> Pair.of(TestEntities.PIECE, id)).toList()); + entities.addAll(List.of( + Pair.of(TestEntities.TITLES, titleId), + Pair.of(TestEntities.PO_LINE, poLineId), + Pair.of(TestEntities.PURCHASE_ORDER, orderId) + )); + for (var entry : entities) { + deleteData(entry.getLeft().getEndpointWithId(), entry.getRight()).then().statusCode(204); + } + } + + private String getEntity(String path, String id, String... replacements) { + var json = new JsonObject(getFile(path)).put("id", id); + for (int i = 0; i < replacements.length; i += 2) { + json.put(replacements[i], replacements[i + 1]); + } + return json.encode(); + } + } diff --git a/src/test/java/org/folio/rest/impl/TestBase.java b/src/test/java/org/folio/rest/impl/TestBase.java index 0033d2e1..84d81885 100644 --- a/src/test/java/org/folio/rest/impl/TestBase.java +++ b/src/test/java/org/folio/rest/impl/TestBase.java @@ -192,6 +192,14 @@ Response putData(String endpoint, String id, String input, Headers headers) thro .put(storageUrl(endpoint)); } + Response putData(String endpoint, String input, Headers headers) throws MalformedURLException { + return given() + .headers(headers) + .contentType(ContentType.JSON) + .body(input) + .put(storageUrl(endpoint)); + } + Response putData(String endpoint, String id, String input) throws MalformedURLException { return putData(endpoint, id, input, new Headers(TENANT_HEADER)); } diff --git a/src/test/java/org/folio/services/piece/PieceServiceTest.java b/src/test/java/org/folio/services/piece/PieceServiceTest.java index ad60aab7..00e51c56 100644 --- a/src/test/java/org/folio/services/piece/PieceServiceTest.java +++ b/src/test/java/org/folio/services/piece/PieceServiceTest.java @@ -8,6 +8,8 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verifyNoInteractions; import java.util.List; import java.util.UUID; @@ -289,4 +291,22 @@ void shouldUpdatePieces(Vertx vertx, VertxTestContext testContext) { }); } + @Test + void shouldNotUpdatePiecesWhenEmpty(Vertx vertx, VertxTestContext testContext) { + new DBClient(vertx, TEST_TENANT).getPgClient().withConn(connection -> { + var conn = spy(connection); + List piecesToUpdate = List.of(); + var updatePiecesFuture = pieceService.updatePieces(piecesToUpdate, conn, TEST_TENANT); + + return testContext.assertComplete(updatePiecesFuture) + .onComplete(ar -> { + List actPieces = ar.result(); + testContext.verify(() -> { + assertThat(actPieces, is(piecesToUpdate)); + verifyNoInteractions(conn); + }); + testContext.completeNow(); + }); + }); + } }