diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/publication/PublicationJpaAdapter.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/publication/PublicationJpaAdapter.java index 5483d93..8f1d075 100644 --- a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/publication/PublicationJpaAdapter.java +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/publication/PublicationJpaAdapter.java @@ -1,5 +1,7 @@ package org.codiki.infrastructure.publication; +import static java.util.Collections.reverseOrder; +import static java.util.Comparator.comparingInt; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -8,6 +10,7 @@ import org.codiki.domain.publication.model.Publication; import org.codiki.domain.publication.model.search.PublicationSearchCriterion; import org.codiki.domain.publication.port.PublicationPort; import org.codiki.infrastructure.publication.model.PublicationEntity; +import org.codiki.infrastructure.publication.model.PublicationSearchResult; import org.codiki.infrastructure.publication.repository.PublicationRepository; import org.springframework.stereotype.Component; @@ -25,7 +28,7 @@ public class PublicationJpaAdapter implements PublicationPort { } @Override - public void save(final Publication publication) { + public void save(Publication publication) { PublicationEntity newPublicationEntity = new PublicationEntity(publication); repository.save(newPublicationEntity); } @@ -47,6 +50,9 @@ public class PublicationJpaAdapter implements PublicationPort { return repository.search(adaptedCriteria) .stream() .map(PublicationEntity::toDomain) + .map(publication -> new PublicationSearchResult(publication, criteria)) + .sorted(reverseOrder(comparingInt(PublicationSearchResult::getSearchScore))) + .map(PublicationSearchResult::getPublication) .toList(); } } diff --git a/codiki-infrastructure/src/main/java/org/codiki/infrastructure/publication/model/PublicationSearchResult.java b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/publication/model/PublicationSearchResult.java new file mode 100644 index 0000000..176d8a6 --- /dev/null +++ b/codiki-infrastructure/src/main/java/org/codiki/infrastructure/publication/model/PublicationSearchResult.java @@ -0,0 +1,117 @@ +package org.codiki.infrastructure.publication.model; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS; +import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION; +import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT; +import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE; +import org.codiki.domain.publication.model.Publication; +import org.codiki.domain.publication.model.search.PublicationSearchCriterion; +import org.codiki.domain.publication.model.search.PublicationSearchField; + +import lombok.Getter; + +@Getter +public class PublicationSearchResult { + private final Publication publication; + private final int searchScore; + + public PublicationSearchResult( + Publication publication, + List criteria + ) { + this.publication = publication; + + int score = 0; + + score += computeCriterionScoreForField(TITLE, publication, criteria, 1000, 10, 3); + score += computeCriterionScoreForField(DESCRIPTION, publication, criteria, 100, 7, 2); + score += computeCriterionScoreForField(TEXT, publication, criteria, 100, 4, 1); + + searchScore = score; + } + + private static int computeCriterionScoreForField( + PublicationSearchField field, + Publication publication, + List criteria, + int bountyForPerfectMatch, + int bountyForWordMatch, + int bountyForWordContaingMatch + ) { + return getLowercaseFieldValue(field, publication) + .map(fieldValue -> + criteria.stream() + .filter(criterion -> criterion.searchType() == CONTAINS) + .filter(criterion -> criterion.searchField() == field) + .map(PublicationSearchCriterion::value) + .filter(String.class::isInstance) + .map(String.class::cast) + .map(String::toLowerCase) + .map(criterionValue -> + computeCriterionScore( + criterionValue, + fieldValue, + bountyForPerfectMatch, + bountyForWordMatch, + bountyForWordContaingMatch + ) + ) + .mapToInt(Integer::intValue) + .sum() + ) + .orElse(0); + } + + private static Optional getLowercaseFieldValue(PublicationSearchField field, Publication publication) { + return Optional.ofNullable( + switch (field) { + case TITLE -> publication.title(); + case DESCRIPTION -> publication.description(); + case TEXT -> publication.text(); + default -> null; + } + ).map(String::toLowerCase); + } + + private static int computeCriterionScore( + String criterionValue, + String publicationTitle, + int bountyForPerfectMatch, + int bountyForWordMatch, + int bountyForWordContaingMatch + ) { + int result = 0; + + if (publicationTitle.equals(criterionValue)) { + result = bountyForPerfectMatch; + } else if (publicationTitle.contains(criterionValue)) { + result = Stream.of(publicationTitle.split(" ")) + .map(titleWord -> computeWordScore(criterionValue, titleWord, bountyForWordMatch, bountyForWordContaingMatch)) + .mapToInt(Integer::intValue) + .sum(); + } + + return result; + } + + private static int computeWordScore( + String criterionValue, + String titleWord, + int bountyForWordMatch, + int bountyForWordContaingMatch + ) { + int result = 0; + + if (titleWord.equals(criterionValue)) { + result = bountyForWordMatch; + } else if (titleWord.contains(criterionValue)) { + result = bountyForWordContaingMatch; + } + + return result; + } +} diff --git a/codiki-infrastructure/src/test/java/org/codiki/infrastructure/publication/model/PublicationSearchResultTest.java b/codiki-infrastructure/src/test/java/org/codiki/infrastructure/publication/model/PublicationSearchResultTest.java new file mode 100644 index 0000000..eae50f9 --- /dev/null +++ b/codiki-infrastructure/src/test/java/org/codiki/infrastructure/publication/model/PublicationSearchResultTest.java @@ -0,0 +1,140 @@ +package org.codiki.infrastructure.publication.model; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication; +import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS; +import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION; +import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT; +import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE; +import org.codiki.domain.publication.model.Publication; +import org.codiki.domain.publication.model.builder.PublicationBuilder; +import org.codiki.domain.publication.model.search.PublicationSearchCriterion; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class PublicationSearchResultTest { + @ParameterizedTest + @MethodSource("arguments_of_constructor_should_compute_score") + void constructor_should_compute_score( + String functionalRule, + String fieldValue, + List criteria, + int expectedPoints + ) { + // given + PublicationBuilder publicationBuilder = aPublication(); + + switch (criteria.getFirst().searchField()) { + case TITLE -> publicationBuilder.withTitle(fieldValue); + case DESCRIPTION -> publicationBuilder.withDescription(fieldValue); + case TEXT -> publicationBuilder.withText(fieldValue); + } + + Publication publication = publicationBuilder.build(); + + // when + PublicationSearchResult publicationSearchResult = new PublicationSearchResult(publication, criteria); + + // then + assertThat(publicationSearchResult.getSearchScore()).isEqualTo(expectedPoints); + } + + private static Stream arguments_of_constructor_should_compute_score() { + return Stream.of( + // TITLE RULES + Arguments.of( + "exact match for title gives 1000 pts", + "Exact title", + List.of(new PublicationSearchCriterion(TITLE, CONTAINS, "Exact title")), + 1000 + ), + Arguments.of( + "one match in title gives 10 pts", + "Exact title", + List.of(new PublicationSearchCriterion(TITLE, CONTAINS, "exact")), + 10 + ), + Arguments.of( + "multiple matches in title gives 10 pts per match", + "One super title with lot of words", + List.of( + new PublicationSearchCriterion(TITLE, CONTAINS, "super"), + new PublicationSearchCriterion(TITLE, CONTAINS, "words") + ), + 20 + ), + Arguments.of( + "one match in title gives 1 pts per match if criterion value is a title word part", + "One super title", + List.of( + new PublicationSearchCriterion(TITLE, CONTAINS, "sup") + ), + 3 + ), + // DESCRIPTION RULES + Arguments.of( + "exact match for description gives 100 pts", + "Exact description", + List.of(new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "Exact description")), + 100 + ), + Arguments.of( + "one match in description gives 7 pts", + "Exact description", + List.of(new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "exact")), + 7 + ), + Arguments.of( + "multiple matches in description gives 7 pts per match", + "One super description with lot of words", + List.of( + new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "super"), + new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "words") + ), + 14 + ), + Arguments.of( + "one match in description gives 2 pts per match if criterion value is a description word part", + "One super description", + List.of( + new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "sup") + ), + 2 + ), + // TEXT RULES + Arguments.of( + "exact match for text gives 100 pts", + "Exact text", + List.of(new PublicationSearchCriterion(TEXT, CONTAINS, "Exact text")), + 100 + ), + Arguments.of( + "one match in text gives 7 pts", + "Exact text", + List.of(new PublicationSearchCriterion(TEXT, CONTAINS, "exact")), + 4 + ), + Arguments.of( + "multiple matches in text gives 7 pts per match", + "One super text with lot of words", + List.of( + new PublicationSearchCriterion(TEXT, CONTAINS, "super"), + new PublicationSearchCriterion(TEXT, CONTAINS, "words") + ), + 8 + ), + Arguments.of( + "one match in text gives 2 pts per match if criterion value is a text word part", + "One super text", + List.of( + new PublicationSearchCriterion(TEXT, CONTAINS, "sup") + ), + 1 + ) + ); + } +} diff --git a/rest-client-collection/Codiki/environments/localhost.bru b/rest-client-collection/Codiki/environments/localhost.bru index 9b27e71..55e10e9 100644 --- a/rest-client-collection/Codiki/environments/localhost.bru +++ b/rest-client-collection/Codiki/environments/localhost.bru @@ -1,7 +1,7 @@ vars { url: http://localhost:8080 publicationId: e23831a6-9cc0-4f3d-9efa-7a1cae191cb1 - bearerToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1YWQ0NjJiOC04ZjllLTRhMjYtYmI4Ni1jNzRmZWY1ZDExYjYiLCJleHAiOjE3MTA1MjE5NzN9.KgYeDYNjgM4ndv_An0fAoOdx7qHRJDXuUPnBEcn6es5kn2g-HDjgt1n_s5CD3_F13alBdR18--9dZlWV8qQmHg + bearerToken: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1YWQ0NjJiOC04ZjllLTRhMjYtYmI4Ni1jNzRmZWY1ZDExYjYiLCJleHAiOjE3MTA1MjY1MDh9.DvfA5uxPMjydRaAkocimAHGo9bfJmTu7hEBIKlaMBd7Qu4XFD1OSL58u8VHnFBU2EiBitRtJRUdidERphkww0Q categoryId: 172fa901-3f4b-4540-92f3-1c15820e8ec9 pictureId: 65b660b7-66bb-4e4a-a62c-fd0ca101f972 }