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

feat: add optional filtering to title search #99

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@
<version>${lucene.version}</version>
</dependency>

<!-- For fuzzy searching -->
<dependency>
<groupId>me.xdrop</groupId>
<artifactId>fuzzywuzzy</artifactId>
<version>1.4.0</version>
</dependency>

<!-- For postgres -->
<dependency>
<groupId>org.postgresql</groupId>
Expand Down
18 changes: 15 additions & 3 deletions src/main/kotlin/no/nb/bikube/core/controller/CoreController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import no.nb.bikube.core.exception.NotSupportedException
import no.nb.bikube.core.model.CatalogueRecord
import no.nb.bikube.core.model.Item
import no.nb.bikube.core.model.Title
import no.nb.bikube.core.service.SearchFilterService
import no.nb.bikube.newspaper.service.NewspaperService
import no.nb.bikube.newspaper.service.TitleIndexService
import org.springframework.http.MediaType
Expand All @@ -36,7 +37,8 @@ import java.time.LocalDate
@RequestMapping("")
class CoreController (
private val newspaperService: NewspaperService,
private val titleIndexService: TitleIndexService
private val titleIndexService: TitleIndexService,
private val searchFilterService: SearchFilterService
){
companion object {
const val DATE_REGEX = "^(17|18|19|20)\\d{2}(-)?(0[1-9]|1[0-2])(-)?(0[1-9]|[12][0-9]|3[01])$"
Expand Down Expand Up @@ -88,12 +90,22 @@ class CoreController (
ApiResponse(responseCode = "500", description = "Server error", content = [Content()])
])
fun searchTitle(
@Parameter(description = "Search term")
@RequestParam searchTerm: String,
@RequestParam materialType: MaterialType
@Parameter(description = "Material type of the titles to search for")
@RequestParam materialType: MaterialType,
@Parameter(description = "Date in ISO-8601 format (YYYY-MM-DD). " +
"If provided, only titles with start and end date that includes this date will be returned.")
@RequestParam date: LocalDate? = null,
@Parameter(description = "Whether or not to select only the best match for search term")
@RequestParam selectBestMatch: Boolean = false
): ResponseEntity<List<CatalogueRecord>> {
if (searchTerm.isEmpty()) throw BadRequestBodyException("Search term cannot be empty.")
return when(materialTypeToCatalogueName(materialType)) {
CatalogueName.COLLECTIONS -> ResponseEntity.ok(titleIndexService.searchTitle(searchTerm))
CatalogueName.COLLECTIONS -> {
val searchResult = titleIndexService.searchTitle(searchTerm)
ResponseEntity.ok(searchFilterService.filterSearchResults(searchResult, searchTerm, date, selectBestMatch))
}
else -> throw NotSupportedException("Material type $materialType is not supported.")
}
}
Expand Down
31 changes: 31 additions & 0 deletions src/main/kotlin/no/nb/bikube/core/service/SearchFilterService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package no.nb.bikube.core.service

import me.xdrop.fuzzywuzzy.FuzzySearch
import no.nb.bikube.core.model.Title
import no.nb.bikube.core.util.logger
import org.springframework.stereotype.Service
import java.time.LocalDate

@Service
class SearchFilterService {
fun filterSearchResults(searchResult: List<Title>, searchTerm: String, date: LocalDate?, selectBestMatch: Boolean?): List<Title> {
val filteredResult = date?.let {
searchResult.filter { title ->
(title.startDate == null || !it.isBefore(title.startDate)) &&
(title.endDate == null || !it.isAfter(title.endDate))
}
} ?: searchResult

val scores = FuzzySearch.extractAll(searchTerm, filteredResult.map { it.name })
logger().debug("Fuzzy search scores {}", scores)

val bestMatch = scores.maxByOrNull { it.score }
logger().debug("Fuzzy search best match {}", bestMatch)

return if (selectBestMatch == true && bestMatch != null && bestMatch.score > 50) {
listOf(filteredResult[bestMatch.index])
} else {
filteredResult
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.col
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockItemA
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockManifestationA
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockTitleA
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsModelMockTitleB
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsPartsObjectMockItemA
import no.nb.bikube.catalogue.collections.CollectionsModelMockData.Companion.collectionsPartsObjectMockManifestationA
import no.nb.bikube.catalogue.collections.enum.CollectionsFormat
Expand All @@ -30,9 +31,11 @@ import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec
import org.springframework.test.web.reactive.server.expectBody
import org.springframework.test.web.reactive.server.returnResult
import org.springframework.web.util.UriBuilder
import reactor.core.publisher.Mono
import reactor.kotlin.test.test
import java.time.Duration
import java.time.LocalDate

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
Expand Down Expand Up @@ -89,13 +92,21 @@ class CoreControllerIntegrationTest (
.exchange()
}

private fun searchTitle(search: String, materialType: MaterialType): ResponseSpec {
private fun searchTitle(search: String, materialType: MaterialType, date: LocalDate? = null, selectBestMatch: Boolean? = null): ResponseSpec {
return webClient
.get()
.uri { uri ->
uri.pathSegment("title", "search")
.queryParam("searchTerm", search)
.queryParam("materialType", materialType)
.apply {
if (date != null) {
queryParam("date", date)
}
if (selectBestMatch != null) {
queryParam("selectBestMatch", selectBestMatch)
}
}
.build()
}
.exchange()
Expand Down Expand Up @@ -322,6 +333,29 @@ class CoreControllerIntegrationTest (
.verifyComplete()
}

@Test
fun `get-title-search endpoint should return best match if query parameter is present`() {
searchTitle("Bikubeavisen", MaterialType.NEWSPAPER, selectBestMatch = true)
.returnResult<Title>()
.responseBody
.test()
.expectSubscription()
.expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleA.getFirstObject()))
.verifyComplete()
}

@Test
fun `get-title-search endpoint should filter by date if query parameter is present`() {
searchTitle("Bikubeavisen", MaterialType.NEWSPAPER, date = LocalDate.parse("2021-01-01"))
.returnResult<Title>()
.responseBody
.test()
.expectSubscription()
.expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleA.getFirstObject()))
.expectNext(mapCollectionsObjectToGenericTitle(collectionsModelMockTitleB.getFirstObject()))
.verifyComplete()
}

@Test
fun `search-item endpoint should return 200 OK with all items`() {
webClient
Expand Down
27 changes: 27 additions & 0 deletions src/test/kotlin/no/nb/bikube/core/controller/CoreControllerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import no.nb.bikube.core.enum.MaterialType
import no.nb.bikube.core.exception.BadRequestBodyException
import no.nb.bikube.core.exception.NotSupportedException
import no.nb.bikube.core.model.Title
import no.nb.bikube.core.service.SearchFilterService
import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperItemMockA
import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperTitleMockA
import no.nb.bikube.newspaper.NewspaperMockData.Companion.newspaperTitleMockB
Expand Down Expand Up @@ -35,6 +36,9 @@ class CoreControllerTest {
@MockkBean
private lateinit var titleIndexService: TitleIndexService

@MockkBean
private lateinit var searchFilterService: SearchFilterService

@Test
fun `get single item for newspaper should return item in body`() {
every { newspaperService.getSingleItem(any()) } returns Mono.just(newspaperItemMockA.copy())
Expand Down Expand Up @@ -97,6 +101,10 @@ class CoreControllerTest {
newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
)

every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns listOf(
newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
)

Assertions.assertEquals(
coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!,
listOf(
Expand All @@ -117,6 +125,7 @@ class CoreControllerTest {
@Test
fun `search title should call on titleIndexService function when materialType is NEWSPAPER`() {
every { titleIndexService.searchTitle(any()) } returns emptyList()
every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns emptyList()

Assertions.assertEquals(
coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!,
Expand All @@ -126,6 +135,24 @@ class CoreControllerTest {
verify(exactly = 1) { titleIndexService.searchTitle(any()) }
}

@Test
fun `search title should call on searchFilterService function when materialType is NEWSPAPER`() {
every { titleIndexService.searchTitle(any()) } returns listOf(
newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
)
every { searchFilterService.filterSearchResults(any(), any(), any(), any()) } returns listOf(
newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
)

Assertions.assertEquals(
coreController.searchTitle("Avis", MaterialType.NEWSPAPER).body!!,
listOf(
newspaperTitleMockA.copy(), newspaperTitleMockB.copy()
)
)
verify(exactly = 1) { searchFilterService.filterSearchResults(any(), any(), any(), any()) }
}

@Test
fun `search item should return a list of items matching criteria`() {
every { newspaperService.getItemsByTitleAndDate(any(), any(), any()) } returns Flux.just(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package no.nb.bikube.core.service

import no.nb.bikube.core.enum.MaterialType
import no.nb.bikube.core.model.Title
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import java.time.LocalDate

@SpringBootTest
@ActiveProfiles("test")
class SearchFilterServiceTest {
@Autowired
private lateinit var searchFilterService: SearchFilterService

private val titleA: Title = Title(
name = "A-Avisen",
startDate = LocalDate.parse("1900-01-01"),
endDate = LocalDate.parse("1925-12-31"),
catalogueId = "1",
publisher = null,
publisherPlace = null,
language = null,
materialType = MaterialType.NEWSPAPER.norwegian
)

private val titleB = Title(
name = "B-Avisen",
startDate = LocalDate.parse("1950-01-01"),
endDate = LocalDate.parse("1999-12-31"),
catalogueId = "2",
publisher = null,
publisherPlace = null,
language = null,
materialType = MaterialType.NEWSPAPER.norwegian
)

private val titleC = Title(
name = "C-Avisen",
startDate = LocalDate.parse("1998-01-01"),
endDate = null,
catalogueId = "3",
publisher = null,
publisherPlace = null,
language = null,
materialType = MaterialType.NEWSPAPER.norwegian
)

private val results: List<Title> = listOf(titleA, titleB, titleC)

@Test
fun `filterSearchResults should filter by date`() {
val date1 = LocalDate.parse("1990-01-01")
val filteredResults1 = searchFilterService.filterSearchResults(results, "", date1, false)
assert(filteredResults1.size == 1)
assert(filteredResults1.contains(titleB))

val date2 = LocalDate.parse("1950-01-01")
val filteredResults2 = searchFilterService.filterSearchResults(results, "", date2, true)
assert(filteredResults2.size == 1)
assert(filteredResults2.contains(titleB))

val date3 = LocalDate.parse("1999-01-01")
val filteredResults3 = searchFilterService.filterSearchResults(results, "", date3, false)
assert(filteredResults3.size == 2)
assert(filteredResults3.contains(titleB))
assert(filteredResults3.contains(titleC))
}

@Test
fun `filterSearchResults should select best match`() {
val searchTerm = "B-Avisen"
val filteredResults = searchFilterService.filterSearchResults(results, searchTerm, null, true)
assert(filteredResults.size == 1)
assert(filteredResults.contains(titleB))
}

@Test
fun `filterSearchResults should return original results list if best match has too low score`() {
val searchTerm = "En helt annen tittel" // Gives 45 score
val filteredResults = searchFilterService.filterSearchResults(results, searchTerm, null, true)
assert(filteredResults.size == 3)
assert(filteredResults.containsAll(listOf(titleA, titleB, titleC)))
}

}
Loading