From 46826dea3dd3213407ca3be0e776e35152ce5b26 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 25 Nov 2024 09:45:56 -0800 Subject: [PATCH] feat(FSADI1-1650): added new search endpoint (#269) Co-authored-by: Maria Martinez --- .../controller/ClientSearchController.java | 83 +++++++++++++++++++ .../repository/ForestClientRepository.java | 17 ++++ .../legacy/service/ClientSearchService.java | 51 ++++++++++++ ...ClientSearchControllerIntegrationTest.java | 35 ++++++++ 4 files changed, 186 insertions(+) diff --git a/src/main/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchController.java b/src/main/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchController.java index 3777175..8d4ac8f 100644 --- a/src/main/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchController.java +++ b/src/main/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchController.java @@ -116,4 +116,87 @@ public Flux searchClients( .putIfAbsent(ApplicationConstants.X_TOTAL_COUNT, List.of(dto.getCount().toString()))); } + /** + * Searches for clients based on the provided parameters using a fuzzy match algorithm. + * The search is case-insensitive and has a threshold cutout of 0.8 for the fuzzy match. + * + * @param page the one-based page number to retrieve, defaults to 0 if not provided. + * @param size the number of results per page, defaults to 10 if not provided. + * @param name the name of the client to search for (optional). + * @param acronym the acronym of the client to search for (optional). + * @param number the unique number of the client to search for (optional). + * @param serverResponse the {@link ServerHttpResponse} to include response headers. + * @return a reactive stream of {@link ClientPublicViewDto} objects representing matching + * clients. + * + * @apiNote This method provides a paginated, fuzzy search for client details. Results + * include a total record count in the response headers under {@code X-Total-Count}. + */ + @GetMapping("/by") + @Operation( + summary = "Search for clients", + description = """ + Search for clients based on the provided parameters. + It uses a fuzzy match to search for the client name. + The cutout for the fuzzy match is 0.8. The search is case insensitive.""", + tags = {"Client Search API"}, + responses = { + @ApiResponse( + responseCode = "200", + description = "Successfully retrieved clients", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema( + schema = @Schema( + name = "ClientView", + implementation = ClientPublicViewDto.class + ) + ) + ), + headers = { + @Header( + name = ApplicationConstants.X_TOTAL_COUNT, + description = "Total number of records found" + ) + } + ) + } + ) + public Flux searchByAcronymNameNumber( + @Parameter(description = "The one index page number, defaults to 0", example = "0") + @RequestParam(value = "page", required = false, defaultValue = "0") + Integer page, + + @Parameter(description = "The amount of data to be returned per page, defaults to 10", + example = "10") + @RequestParam(value = "size", required = false, defaultValue = "10") + Integer size, + + @Parameter(description = "The name of the client you're searching", example = "Western Forest Products") + @RequestParam(value = "name", required = false) + String name, + + @Parameter(description = "The acronym of the client you're searching", example = "WFPS") + @RequestParam(value = "acronym", required = false) + String acronym, + + @Parameter(description = "The number of the client you're searching", example = "00000001") + @RequestParam(value = "number", required = false) + String number, + + ServerHttpResponse serverResponse + ) { + + log.info("Searching for clients with name {}, acronym {}, number {}", name, acronym, number); + return + clientSearchService + .searchByAcronymNameNumber(name, acronym, number) + .flatMapMany(criteria -> clientSearchService.searchClientByQuery(criteria, page, size)) + .doOnNext(client -> log.info("Found client with id {}", client.getClientNumber())) + .doOnNext(dto -> serverResponse.getHeaders() + .putIfAbsent(ApplicationConstants.X_TOTAL_COUNT, + List.of(dto.getCount().toString()))); + + } + } diff --git a/src/main/java/ca/bc/gov/api/oracle/legacy/repository/ForestClientRepository.java b/src/main/java/ca/bc/gov/api/oracle/legacy/repository/ForestClientRepository.java index 2681c6e..2061bd4 100644 --- a/src/main/java/ca/bc/gov/api/oracle/legacy/repository/ForestClientRepository.java +++ b/src/main/java/ca/bc/gov/api/oracle/legacy/repository/ForestClientRepository.java @@ -2,6 +2,7 @@ import ca.bc.gov.api.oracle.legacy.entity.ForestClientEntity; import org.springframework.data.domain.Pageable; +import org.springframework.data.r2dbc.repository.Query; import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; import org.springframework.data.repository.reactive.ReactiveCrudRepository; import org.springframework.data.repository.reactive.ReactiveSortingRepository; @@ -25,4 +26,20 @@ Flux findByClientNumberContainingOrClientNameContaining( Mono countByClientNumberContainingOrClientNameContaining(String clientNumber, String clientName); + + + @Query(value = """ + SELECT + CLIENT_NUMBER + FROM THE.FOREST_CLIENT + WHERE + UTL_MATCH.JARO_WINKLER_SIMILARITY(CLIENT_NAME, :clientName) >= 80 + OR UTL_MATCH.JARO_WINKLER_SIMILARITY(LEGAL_FIRST_NAME, :clientName) >= 80 + OR UTL_MATCH.JARO_WINKLER_SIMILARITY(LEGAL_MIDDLE_NAME, :clientName) >= 80 + OR UTL_MATCH.JARO_WINKLER_SIMILARITY(TRIM(COALESCE(LEGAL_FIRST_NAME || ' ', '') || TRIM(COALESCE(LEGAL_MIDDLE_NAME || ' ', '')) || COALESCE(CLIENT_NAME, '')), :clientName) >= 80 + OR CLIENT_ACRONYM = :acronym + OR CLIENT_NUMBER = :clientNumber + ORDER BY CLIENT_NUMBER""") + Flux searchNumberByNameAcronymNumber(String clientName, String acronym, String clientNumber); + } diff --git a/src/main/java/ca/bc/gov/api/oracle/legacy/service/ClientSearchService.java b/src/main/java/ca/bc/gov/api/oracle/legacy/service/ClientSearchService.java index 0aa0d3c..c19f4e8 100644 --- a/src/main/java/ca/bc/gov/api/oracle/legacy/service/ClientSearchService.java +++ b/src/main/java/ca/bc/gov/api/oracle/legacy/service/ClientSearchService.java @@ -4,10 +4,12 @@ import ca.bc.gov.api.oracle.legacy.dto.ClientPublicViewDto; import ca.bc.gov.api.oracle.legacy.entity.ForestClientEntity; +import ca.bc.gov.api.oracle.legacy.repository.ForestClientRepository; import ca.bc.gov.api.oracle.legacy.util.ClientMapper; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; @@ -15,6 +17,7 @@ import org.springframework.data.relational.core.query.Query; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; @Service @RequiredArgsConstructor @@ -22,6 +25,7 @@ public class ClientSearchService { private final R2dbcEntityTemplate template; + private final ForestClientRepository forestClientRepository; /** * This method is used to create a search criteria based on a list of client IDs. It first logs @@ -104,4 +108,51 @@ public Flux searchClientByQuery( ) .doOnNext(client -> log.info("Found client with id {}", client.getClientNumber())); } + + /** + * Constructs a search {@link Criteria} object based on the provided client name, acronym, or + * number. + * This method normalizes input values for case-insensitive searches and validates the client + * number. + * + * @param name the name of the client to search for, or null if not specified. + * @param acronym the acronym of the client to search for, or null if not specified. + * @param number the unique number of the client to search for, or null if not specified. + * @return a {@link Mono} emitting the constructed {@link Criteria} object for the search. + * + * @implNote Input values are transformed to uppercase for case-insensitivity. The client + * number is validated using {@link #checkClientNumber(String)}. Repository results are + * mapped to a search criteria object. + */ + public Mono searchByAcronymNameNumber(String name, String acronym, String number) { + log.info("Searching for clients with name {}, acronym {} or number {}", name, acronym, number); + + String searchName = StringUtils.isNotBlank(name) ? name.toUpperCase() : null; + String searchAcronym = StringUtils.isNotBlank(acronym) ? acronym.toUpperCase() : null; + String searchNumber = StringUtils.isNotBlank(number) ? checkClientNumber(number) : null; + + return + forestClientRepository + .searchNumberByNameAcronymNumber( + searchName, + searchAcronym, + searchNumber + ) + .collectList() + .map(this::searchById); + + } + + private String checkClientNumber(String clientNumber) { + if(StringUtils.isBlank(clientNumber)) { + return clientNumber; + } + + try { + Integer parsed = Integer.parseInt(clientNumber); + return String.format("%08d", parsed); + } catch (NumberFormatException nfe) { + return clientNumber; + } + } } diff --git a/src/test/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchControllerIntegrationTest.java b/src/test/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchControllerIntegrationTest.java index 8da99ed..8546942 100644 --- a/src/test/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchControllerIntegrationTest.java +++ b/src/test/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchControllerIntegrationTest.java @@ -2,8 +2,10 @@ import ca.bc.gov.api.oracle.legacy.AbstractTestContainerIntegrationTest; import ca.bc.gov.api.oracle.legacy.dto.ClientPublicViewDto; +import java.util.Optional; import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -36,6 +38,39 @@ void shouldSearchClientsById(Integer returnSize, Object[] ids) { .hasSize(returnSize); } + @ParameterizedTest + @MethodSource("searchByNameAcronymNumber") + @DisplayName("Search clients by name, acronym, or number") + void shouldSearchByNameAcronymOrNumber(Integer returnSize, String name, String acronym, String number) { + webTestClient + .get() + .uri(uriBuilder -> uriBuilder + .path("/api/clients/search/by") + .queryParamIfPresent("name", Optional.ofNullable(name)) + .queryParamIfPresent("acronym", Optional.ofNullable(acronym)) + .queryParamIfPresent("number", Optional.ofNullable(number)) + .build() + ) + .exchange() + .expectStatus().isOk() + .expectBodyList(ClientPublicViewDto.class) + .hasSize(returnSize); + } + + + private static Stream searchByNameAcronymNumber() { + return Stream.of( + Arguments.of(1, "INDIA",null,null), + Arguments.of(8, null,"SAMPLIBC",null), + Arguments.of(1, null,null,"00000001"), + Arguments.of(1, null,null,"1"), + + Arguments.of(0, "XXAABBDA",null,null), + Arguments.of(0, null,"XXAABB",null), + Arguments.of(0, null,null,"12345678") + ); + } + private static Stream searchById() { return Stream.of(