From d401a7cf41673202c3a7f467ab498d324b719306 Mon Sep 17 00:00:00 2001 From: PBobylev Date: Fri, 3 Apr 2026 14:43:08 +0500 Subject: [PATCH 1/3] MODLD-1029: Analytical Entries added to Work Response DTO --- NEWS.md | 1 + .../base/SingleResourceMapperImpl.java | 9 +- .../base/SingleResourceMapperUnit.java | 2 + .../common/work/sub/LightWorkMapperUnit.java | 60 ++++++++ .../folio/linked/data/util/ResourceUtils.java | 5 + .../entity/PrimaryTitleEntityValidator.java | 7 +- .../schema/resource/common/LightWork.json | 21 +++ .../resource/response/WorkResponse.json | 7 + .../mappings/work/lightwork/LightWorkIT.java | 134 ++++++++++++++++++ .../PrimaryTitleEntityValidatorTest.java | 15 ++ 10 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/folio/linked/data/mapper/dto/resource/common/work/sub/LightWorkMapperUnit.java create mode 100644 src/main/resources/swagger.api/schema/resource/common/LightWork.json create mode 100644 src/test/java/org/folio/linked/data/e2e/mappings/work/lightwork/LightWorkIT.java diff --git a/NEWS.md b/NEWS.md index 6a371237e..3c96e42cf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -119,6 +119,7 @@ - Remove AUTO_SAVE_MARC_BIB_AS_GRAPH [MODLD-1025](https://folio-org.atlassian.net/browse/MODLD-1025) - Preserve user context and headers in Kafka processing. Remove all references to system user [MODLD-1031](https://folio-org.atlassian.net/browse/MODLD-1031) - Make profile settings transactional to address inconsistency bug [MODLD-1030](https://folio-org.atlassian.net/browse/MODLD-1030) +- Analytical Entries added to Work Response DTO (MODLD-1029)[https://folio-org.atlassian.net/browse/MODLD-1029] ## 1.0.4 (04-24-2025) - Work Edit form - Instance read-only section: "Notes about the instance" data is not shown [MODLD-716](https://folio-org.atlassian.net/browse/MODLD-716) diff --git a/src/main/java/org/folio/linked/data/mapper/dto/resource/base/SingleResourceMapperImpl.java b/src/main/java/org/folio/linked/data/mapper/dto/resource/base/SingleResourceMapperImpl.java index 09dcae8c2..3f493f53a 100644 --- a/src/main/java/org/folio/linked/data/mapper/dto/resource/base/SingleResourceMapperImpl.java +++ b/src/main/java/org/folio/linked/data/mapper/dto/resource/base/SingleResourceMapperImpl.java @@ -15,6 +15,7 @@ import static org.folio.linked.data.util.ResourceUtils.getTypeUris; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.NonNull; import lombok.RequiredArgsConstructor; @@ -56,15 +57,13 @@ public

Resource toEntity(@NonNull Object dto, @NonNull Class

parentReques @Override public D toDto(@NonNull Resource source, @NonNull D parentDto, Resource parentResource, Predicate predicate) { - // Of all the types of the resource, take the first one that has a mapper - var resourceMapper = source.getTypes() + return source.getTypes() .stream() .map(type -> getMapperUnit(type.getUri(), predicate, parentDto.getClass(), null)) .flatMap(Optional::stream) - .findFirst(); - - return resourceMapper .map(mapper -> mapper.toDto(source, parentDto, new ResourceMappingContext(parentResource, predicate))) + .filter(Objects::nonNull) + .findFirst() .orElseGet(() -> { var types = String.join(", ", getTypeUris(source)); log.debug( diff --git a/src/main/java/org/folio/linked/data/mapper/dto/resource/base/SingleResourceMapperUnit.java b/src/main/java/org/folio/linked/data/mapper/dto/resource/base/SingleResourceMapperUnit.java index 68fb297f1..931ff24e8 100644 --- a/src/main/java/org/folio/linked/data/mapper/dto/resource/base/SingleResourceMapperUnit.java +++ b/src/main/java/org/folio/linked/data/mapper/dto/resource/base/SingleResourceMapperUnit.java @@ -3,9 +3,11 @@ import java.util.Set; import org.folio.ld.dictionary.model.Predicate; import org.folio.linked.data.model.entity.Resource; +import org.jspecify.annotations.Nullable; public interface SingleResourceMapperUnit { + @Nullable

P toDto(Resource resourceToConvert, P parentDto, ResourceMappingContext context); Resource toEntity(Object dto, Resource parentEntity); diff --git a/src/main/java/org/folio/linked/data/mapper/dto/resource/common/work/sub/LightWorkMapperUnit.java b/src/main/java/org/folio/linked/data/mapper/dto/resource/common/work/sub/LightWorkMapperUnit.java new file mode 100644 index 000000000..8a824cb54 --- /dev/null +++ b/src/main/java/org/folio/linked/data/mapper/dto/resource/common/work/sub/LightWorkMapperUnit.java @@ -0,0 +1,60 @@ +package org.folio.linked.data.mapper.dto.resource.common.work.sub; + +import static org.folio.ld.dictionary.PredicateDictionary.IS_PART_OF; +import static org.folio.ld.dictionary.PredicateDictionary.OTHER_EDITION; +import static org.folio.ld.dictionary.PredicateDictionary.OTHER_VERSION; +import static org.folio.ld.dictionary.PredicateDictionary.RELATED_WORK; +import static org.folio.ld.dictionary.PropertyDictionary.LABEL; +import static org.folio.ld.dictionary.ResourceTypeDictionary.LIGHT_RESOURCE; +import static org.folio.ld.dictionary.ResourceTypeDictionary.SERIES; +import static org.folio.linked.data.util.ResourceUtils.getFirstPropertyValue; + +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.folio.linked.data.domain.dto.LightWork; +import org.folio.linked.data.domain.dto.WorkResponse; +import org.folio.linked.data.exception.RequestProcessingExceptionBuilder; +import org.folio.linked.data.mapper.dto.resource.base.CoreMapper; +import org.folio.linked.data.mapper.dto.resource.base.MapperUnit; +import org.folio.linked.data.mapper.dto.resource.base.SingleResourceMapperUnit; +import org.folio.linked.data.model.entity.Resource; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@MapperUnit(type = LIGHT_RESOURCE, predicate = {IS_PART_OF, OTHER_EDITION, OTHER_VERSION, RELATED_WORK}, + requestDto = LightWork.class) +public class LightWorkMapperUnit implements SingleResourceMapperUnit { + + private static final Set> SUPPORTED_PARENTS = Set.of( + WorkResponse.class + ); + private final CoreMapper coreMapper; + private final RequestProcessingExceptionBuilder exceptionBuilder; + + @Override + public Set> supportedParents() { + return SUPPORTED_PARENTS; + } + + @Override + public

P toDto(Resource resourceToConvert, P parentDto, ResourceMappingContext context) { + if (resourceToConvert.isOfType(SERIES)) { + return null; + } + if (parentDto instanceof WorkResponse workResponse) { + var lightWork = coreMapper.toDtoWithEdges(resourceToConvert, LightWork.class, false); + lightWork.setId(String.valueOf(resourceToConvert.getId())); + lightWork.setLabel(getFirstPropertyValue(resourceToConvert, LABEL)); + lightWork.setRelation(context.predicate().getUri()); + workResponse.addAnalyticalEntryItem(lightWork); + } + return parentDto; + } + + @Override + public Resource toEntity(Object dto, Resource parentEntity) { + throw exceptionBuilder.notSupportedException(LIGHT_RESOURCE.name(), "Create or update"); + } + +} diff --git a/src/main/java/org/folio/linked/data/util/ResourceUtils.java b/src/main/java/org/folio/linked/data/util/ResourceUtils.java index a8b7377e8..ac1acf339 100644 --- a/src/main/java/org/folio/linked/data/util/ResourceUtils.java +++ b/src/main/java/org/folio/linked/data/util/ResourceUtils.java @@ -142,6 +142,11 @@ public static List getTypeUris(Resource resource) { .toList(); } + public static String getFirstPropertyValue(Resource resource, PropertyDictionary property) { + return getPropertyValues(resource, property) + .getFirst(); + } + public static List getPropertyValues(Resource resource, PropertyDictionary property) { return ofNullable(resource.getDoc()) .map(doc -> doc.get(property.getValue())) diff --git a/src/main/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidator.java b/src/main/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidator.java index eed02247e..eabbf8759 100644 --- a/src/main/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidator.java +++ b/src/main/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidator.java @@ -4,6 +4,7 @@ import static org.apache.commons.collections4.CollectionUtils.isEmpty; import static org.folio.ld.dictionary.PropertyDictionary.MAIN_TITLE; import static org.folio.ld.dictionary.ResourceTypeDictionary.INSTANCE; +import static org.folio.ld.dictionary.ResourceTypeDictionary.LIGHT_RESOURCE; import static org.folio.ld.dictionary.ResourceTypeDictionary.SERIES; import static org.folio.ld.dictionary.ResourceTypeDictionary.TITLE; import static org.folio.ld.dictionary.ResourceTypeDictionary.WORK; @@ -20,7 +21,7 @@ public class PrimaryTitleEntityValidator implements ConstraintValidator()); + + // when + var result = validator.isValid(resource, null); + + // then + assertThat(result).isTrue(); + } + @Test void shouldReturnFalse_ifGivenResourceIsInstanceWithNullOutgoingEdges() { // given From cf698d51d1b38b79488e29c1ada61733072ecf8d Mon Sep 17 00:00:00 2001 From: PBobylev Date: Fri, 3 Apr 2026 14:43:08 +0500 Subject: [PATCH 2/3] MODLD-1029: Analytical Entries added to Work Response DTO --- .../entity/PrimaryTitleEntityValidatorTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidatorTest.java b/src/test/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidatorTest.java index 31d322a76..9c3ea13b4 100644 --- a/src/test/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidatorTest.java +++ b/src/test/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidatorTest.java @@ -28,6 +28,21 @@ void shouldReturnTrue_ifGivenResourceIsNotInstanceOrWork() { // when boolean result = validator.isValid(resource, null); + // then + // then + assertThat(result).isTrue(); + } + + @Test + void shouldReturnTrue_ifGivenResourceIsWorkAndLightResourceWithEmptyOutgoingEdges() { + // given + var resource = new Resource() + .addTypes(WORK, LIGHT_RESOURCE) + .setOutgoingEdges(new HashSet<>()); + + // when + var result = validator.isValid(resource, null); + // then assertThat(result).isTrue(); } From b7ff9a24189a969018faf513a5f3e62136301f9c Mon Sep 17 00:00:00 2001 From: PBobylev Date: Mon, 6 Apr 2026 18:10:50 +0500 Subject: [PATCH 3/3] MODLD-1029: preserve analytical entries during update --- .../edge/ResourceEdgeServiceImpl.java | 38 +++++----- .../edge/ResourceEdgeServiceTest.java | 72 +++++++++++++++---- .../PrimaryTitleEntityValidatorTest.java | 14 ---- 3 files changed, 82 insertions(+), 42 deletions(-) diff --git a/src/main/java/org/folio/linked/data/service/resource/edge/ResourceEdgeServiceImpl.java b/src/main/java/org/folio/linked/data/service/resource/edge/ResourceEdgeServiceImpl.java index 62babd2ff..015c86d26 100644 --- a/src/main/java/org/folio/linked/data/service/resource/edge/ResourceEdgeServiceImpl.java +++ b/src/main/java/org/folio/linked/data/service/resource/edge/ResourceEdgeServiceImpl.java @@ -2,10 +2,16 @@ import static org.folio.ld.dictionary.PredicateDictionary.DISSERTATION; import static org.folio.ld.dictionary.PredicateDictionary.GENRE; +import static org.folio.ld.dictionary.PredicateDictionary.IS_PART_OF; +import static org.folio.ld.dictionary.PredicateDictionary.OTHER_EDITION; +import static org.folio.ld.dictionary.PredicateDictionary.OTHER_VERSION; +import static org.folio.ld.dictionary.PredicateDictionary.RELATED_WORK; +import static org.folio.ld.dictionary.ResourceTypeDictionary.SERIES; import static org.folio.ld.dictionary.ResourceTypeDictionary.WORK; import java.util.Map; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.folio.ld.dictionary.PredicateDictionary; @@ -23,8 +29,16 @@ @RequiredArgsConstructor public class ResourceEdgeServiceImpl implements ResourceEdgeService { - private static final Map> OUTGOING_EDGES_TO_BE_COPIED = Map.of( - WORK, Set.of(DISSERTATION, GENRE) + private static final Map>> + PARENT_TO_OUTGOING_EDGE_AND_CONDITION = Map.of( + WORK, Map.of( + DISSERTATION, edge -> true, + GENRE, edge -> true, + IS_PART_OF, edge -> !edge.getTarget().isOfType(SERIES), + OTHER_EDITION, edge -> true, + OTHER_VERSION, edge -> true, + RELATED_WORK, edge -> true + ) ); private final ResourceModelMapper resourceModelMapper; private final ResourceEdgeRepository resourceEdgeRepository; @@ -68,21 +82,13 @@ public ResourceEdgePk saveNewResourceEdge(Long sourceId, } private Set getOutgoingEdgesToBeCopied(Resource resource) { - var predicatesToBeCopied = getPredicatesToBeCopied(resource); - return getOutgoingEdgesWithPredicate(resource, predicatesToBeCopied); - } - - private Set getOutgoingEdgesWithPredicate(Resource resource, Set predicateUris) { - return resource.getOutgoingEdges().stream() - .filter(edge -> predicateUris.contains(edge.getPredicate().getUri())) - .collect(Collectors.toSet()); - } - - private Set getPredicatesToBeCopied(Resource resource) { - return OUTGOING_EDGES_TO_BE_COPIED.entrySet().stream() + return PARENT_TO_OUTGOING_EDGE_AND_CONDITION.entrySet().stream() .filter(entry -> resource.isOfType(entry.getKey())) - .flatMap(entry -> entry.getValue().stream()) - .map(PredicateDictionary::getUri) + .flatMap(entry -> resource.getOutgoingEdges().stream() + .filter(edge -> PredicateDictionary.fromUri(edge.getPredicate().getUri()) + .map(entry.getValue()::get) + .map(condition -> condition.test(edge)) + .orElse(false))) .collect(Collectors.toSet()); } diff --git a/src/test/java/org/folio/linked/data/service/resource/edge/ResourceEdgeServiceTest.java b/src/test/java/org/folio/linked/data/service/resource/edge/ResourceEdgeServiceTest.java index 391d0911c..50ab0754f 100644 --- a/src/test/java/org/folio/linked/data/service/resource/edge/ResourceEdgeServiceTest.java +++ b/src/test/java/org/folio/linked/data/service/resource/edge/ResourceEdgeServiceTest.java @@ -3,15 +3,22 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.folio.ld.dictionary.PredicateDictionary.DISSERTATION; import static org.folio.ld.dictionary.PredicateDictionary.GENRE; +import static org.folio.ld.dictionary.PredicateDictionary.IS_PART_OF; +import static org.folio.ld.dictionary.PredicateDictionary.OTHER_EDITION; +import static org.folio.ld.dictionary.PredicateDictionary.OTHER_VERSION; +import static org.folio.ld.dictionary.PredicateDictionary.RELATED_WORK; import static org.folio.ld.dictionary.PredicateDictionary.TITLE; +import static org.folio.ld.dictionary.ResourceTypeDictionary.SERIES; import static org.folio.linked.data.test.TestUtil.randomLong; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import java.util.Set; import java.util.stream.Stream; +import org.folio.ld.dictionary.PredicateDictionary; import org.folio.ld.dictionary.ResourceTypeDictionary; import org.folio.linked.data.mapper.ResourceModelMapper; import org.folio.linked.data.model.entity.PredicateEntity; @@ -49,28 +56,47 @@ class ResourceEdgeServiceTest { void saveNewResourceEdge_shouldSaveMappedEdgeResourceWithReferenceToSource() { // given var sourceId = randomLong(); - var edgeModel = new org.folio.ld.dictionary.model.ResourceEdge( - new org.folio.ld.dictionary.model.Resource().setId(sourceId), - new org.folio.ld.dictionary.model.Resource().setId(randomLong()), TITLE); - var mappedEdgeResource = new Resource().setIdAndRefreshEdges(edgeModel.getTarget().getId()); - doReturn(mappedEdgeResource).when(resourceModelMapper).toEntity(edgeModel.getTarget()); - doReturn(new SaveGraphResult(mappedEdgeResource)).when(resourceGraphService).saveMergingGraph(mappedEdgeResource); + var targetModel = new org.folio.ld.dictionary.model.Resource().setId(randomLong()); + var mappedTarget = new Resource().setIdAndRefreshEdges(targetModel.getId()); + doReturn(mappedTarget).when(resourceModelMapper).toEntity(targetModel); + doReturn(new SaveGraphResult(mappedTarget)).when(resourceGraphService).saveMergingGraph(mappedTarget); when(resourceEdgeRepository.save(any(ResourceEdge.class))) .thenAnswer(i -> i.getArgument(0)); // when - var result = resourceEdgeService.saveNewResourceEdge(sourceId, edgeModel.getPredicate(), edgeModel.getTarget()); + var result = resourceEdgeService.saveNewResourceEdge(sourceId, TITLE, targetModel); // then - assertThat(result.getSourceHash()).isEqualTo(edgeModel.getSource().getId()); - assertThat(result.getTargetHash()).isEqualTo(edgeModel.getTarget().getId()); - assertThat(result.getPredicateHash()).isEqualTo(edgeModel.getPredicate().getHash()); + assertThat(result.getSourceHash()).isEqualTo(sourceId); + assertThat(result.getTargetHash()).isEqualTo(targetModel.getId()); + assertThat(result.getPredicateHash()).isEqualTo(TITLE.getHash()); + } + + @Test + void deleteEdgesHavingPredicate_shouldDelegateToRepository() { + // given + var resourceId = randomLong(); + var predicate = PredicateDictionary.GENRE; + var expectedDeletedCount = 2L; + doReturn(expectedDeletedCount).when(resourceEdgeRepository) + .deleteByIdSourceHashAndIdPredicateHash(resourceId, predicate.getHash()); + + // when + var result = resourceEdgeService.deleteEdgesHavingPredicate(resourceId, predicate); + + // then + assertThat(result).isEqualTo(expectedDeletedCount); + verify(resourceEdgeRepository).deleteByIdSourceHashAndIdPredicateHash(resourceId, predicate.getHash()); } static Stream dataProvider() { return Stream.of( - Arguments.of(getWork(), getEmptyWork(), List.of(DISSERTATION.getUri(), - GENRE.getUri())), + Arguments.of(getWork(), getEmptyWork(), List.of( + DISSERTATION.getUri(), GENRE.getUri(), IS_PART_OF.getUri(), + OTHER_EDITION.getUri(), OTHER_VERSION.getUri(), RELATED_WORK.getUri())), + Arguments.of(getWorkWithIsPartOfPointingToSeries(), getEmptyWork(), List.of( + DISSERTATION.getUri(), GENRE.getUri(), + OTHER_EDITION.getUri(), OTHER_VERSION.getUri(), RELATED_WORK.getUri())), Arguments.of(getInstance(), getEmptyWork(), List.of()), Arguments.of(getWork(), getEmptyInstance(), List.of()) ); @@ -141,9 +167,31 @@ private static Resource getWork() { work.addOutgoingEdge(new ResourceEdge(work, new Resource(), TITLE)); work.addOutgoingEdge(new ResourceEdge(work, new Resource(), DISSERTATION)); work.addOutgoingEdge(new ResourceEdge(work, new Resource(), GENRE)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), IS_PART_OF)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), OTHER_EDITION)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), OTHER_VERSION)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), RELATED_WORK)); return work; } + private static Resource getWorkWithIsPartOfPointingToSeries() { + var work = getEmptyWork(); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), TITLE)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), DISSERTATION)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), GENRE)); + work.addOutgoingEdge(new ResourceEdge(work, getSeriesResource(), IS_PART_OF)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), OTHER_EDITION)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), OTHER_VERSION)); + work.addOutgoingEdge(new ResourceEdge(work, new Resource(), RELATED_WORK)); + return work; + } + + private static Resource getSeriesResource() { + var series = new Resource(); + series.setTypes(Set.of(new ResourceTypeEntity(3L, SERIES.getUri(), "series"))); + return series; + } + private static Resource getWorkWithIncomingEdges() { var work = getEmptyWork(); work.addIncomingEdge(new ResourceEdge(new Resource(), work, TITLE)); diff --git a/src/test/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidatorTest.java b/src/test/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidatorTest.java index 9c3ea13b4..639f918cb 100644 --- a/src/test/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidatorTest.java +++ b/src/test/java/org/folio/linked/data/validation/entity/PrimaryTitleEntityValidatorTest.java @@ -47,20 +47,6 @@ void shouldReturnTrue_ifGivenResourceIsWorkAndLightResourceWithEmptyOutgoingEdge assertThat(result).isTrue(); } - @Test - void shouldReturnTrue_ifGivenResourceIsWorkAndLightResourceWithEmptyOutgoingEdges() { - // given - var resource = new Resource() - .addTypes(WORK, LIGHT_RESOURCE) - .setOutgoingEdges(new HashSet<>()); - - // when - var result = validator.isValid(resource, null); - - // then - assertThat(result).isTrue(); - } - @Test void shouldReturnFalse_ifGivenResourceIsInstanceWithNullOutgoingEdges() { // given