Skip to content

Commit 46826de

Browse files
feat(FSADI1-1650): added new search endpoint (#269)
Co-authored-by: Maria Martinez <maria.martinez@gov.bc.ca>
1 parent e48d6c3 commit 46826de

File tree

4 files changed

+186
-0
lines changed

4 files changed

+186
-0
lines changed

src/main/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchController.java

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,87 @@ public Flux<ClientPublicViewDto> searchClients(
116116
.putIfAbsent(ApplicationConstants.X_TOTAL_COUNT, List.of(dto.getCount().toString())));
117117
}
118118

119+
/**
120+
* Searches for clients based on the provided parameters using a fuzzy match algorithm.
121+
* The search is case-insensitive and has a threshold cutout of 0.8 for the fuzzy match.
122+
*
123+
* @param page the one-based page number to retrieve, defaults to 0 if not provided.
124+
* @param size the number of results per page, defaults to 10 if not provided.
125+
* @param name the name of the client to search for (optional).
126+
* @param acronym the acronym of the client to search for (optional).
127+
* @param number the unique number of the client to search for (optional).
128+
* @param serverResponse the {@link ServerHttpResponse} to include response headers.
129+
* @return a reactive stream of {@link ClientPublicViewDto} objects representing matching
130+
* clients.
131+
*
132+
* @apiNote This method provides a paginated, fuzzy search for client details. Results
133+
* include a total record count in the response headers under {@code X-Total-Count}.
134+
*/
135+
@GetMapping("/by")
136+
@Operation(
137+
summary = "Search for clients",
138+
description = """
139+
Search for clients based on the provided parameters.
140+
It uses a fuzzy match to search for the client name.
141+
The cutout for the fuzzy match is 0.8. The search is case insensitive.""",
142+
tags = {"Client Search API"},
143+
responses = {
144+
@ApiResponse(
145+
responseCode = "200",
146+
description = "Successfully retrieved clients",
147+
content = @Content(
148+
mediaType = MediaType.APPLICATION_JSON_VALUE,
149+
array = @ArraySchema(
150+
schema = @Schema(
151+
name = "ClientView",
152+
implementation = ClientPublicViewDto.class
153+
)
154+
)
155+
),
156+
headers = {
157+
@Header(
158+
name = ApplicationConstants.X_TOTAL_COUNT,
159+
description = "Total number of records found"
160+
)
161+
}
162+
)
163+
}
164+
)
165+
public Flux<ClientPublicViewDto> searchByAcronymNameNumber(
166+
@Parameter(description = "The one index page number, defaults to 0", example = "0")
167+
@RequestParam(value = "page", required = false, defaultValue = "0")
168+
Integer page,
169+
170+
@Parameter(description = "The amount of data to be returned per page, defaults to 10",
171+
example = "10")
172+
@RequestParam(value = "size", required = false, defaultValue = "10")
173+
Integer size,
174+
175+
@Parameter(description = "The name of the client you're searching", example = "Western Forest Products")
176+
@RequestParam(value = "name", required = false)
177+
String name,
178+
179+
@Parameter(description = "The acronym of the client you're searching", example = "WFPS")
180+
@RequestParam(value = "acronym", required = false)
181+
String acronym,
182+
183+
@Parameter(description = "The number of the client you're searching", example = "00000001")
184+
@RequestParam(value = "number", required = false)
185+
String number,
186+
187+
ServerHttpResponse serverResponse
188+
) {
189+
190+
log.info("Searching for clients with name {}, acronym {}, number {}", name, acronym, number);
191+
return
192+
clientSearchService
193+
.searchByAcronymNameNumber(name, acronym, number)
194+
.flatMapMany(criteria -> clientSearchService.searchClientByQuery(criteria, page, size))
195+
.doOnNext(client -> log.info("Found client with id {}", client.getClientNumber()))
196+
.doOnNext(dto -> serverResponse.getHeaders()
197+
.putIfAbsent(ApplicationConstants.X_TOTAL_COUNT,
198+
List.of(dto.getCount().toString())));
199+
200+
}
201+
119202
}

src/main/java/ca/bc/gov/api/oracle/legacy/repository/ForestClientRepository.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import ca.bc.gov.api.oracle.legacy.entity.ForestClientEntity;
44
import org.springframework.data.domain.Pageable;
5+
import org.springframework.data.r2dbc.repository.Query;
56
import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor;
67
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
78
import org.springframework.data.repository.reactive.ReactiveSortingRepository;
@@ -25,4 +26,20 @@ Flux<ForestClientEntity> findByClientNumberContainingOrClientNameContaining(
2526

2627
Mono<Long> countByClientNumberContainingOrClientNameContaining(String clientNumber,
2728
String clientName);
29+
30+
31+
@Query(value = """
32+
SELECT
33+
CLIENT_NUMBER
34+
FROM THE.FOREST_CLIENT
35+
WHERE
36+
UTL_MATCH.JARO_WINKLER_SIMILARITY(CLIENT_NAME, :clientName) >= 80
37+
OR UTL_MATCH.JARO_WINKLER_SIMILARITY(LEGAL_FIRST_NAME, :clientName) >= 80
38+
OR UTL_MATCH.JARO_WINKLER_SIMILARITY(LEGAL_MIDDLE_NAME, :clientName) >= 80
39+
OR UTL_MATCH.JARO_WINKLER_SIMILARITY(TRIM(COALESCE(LEGAL_FIRST_NAME || ' ', '') || TRIM(COALESCE(LEGAL_MIDDLE_NAME || ' ', '')) || COALESCE(CLIENT_NAME, '')), :clientName) >= 80
40+
OR CLIENT_ACRONYM = :acronym
41+
OR CLIENT_NUMBER = :clientNumber
42+
ORDER BY CLIENT_NUMBER""")
43+
Flux<String> searchNumberByNameAcronymNumber(String clientName, String acronym, String clientNumber);
44+
2845
}

src/main/java/ca/bc/gov/api/oracle/legacy/service/ClientSearchService.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,28 @@
44

55
import ca.bc.gov.api.oracle.legacy.dto.ClientPublicViewDto;
66
import ca.bc.gov.api.oracle.legacy.entity.ForestClientEntity;
7+
import ca.bc.gov.api.oracle.legacy.repository.ForestClientRepository;
78
import ca.bc.gov.api.oracle.legacy.util.ClientMapper;
89
import java.util.List;
910
import lombok.RequiredArgsConstructor;
1011
import lombok.extern.slf4j.Slf4j;
12+
import org.apache.commons.lang3.StringUtils;
1113
import org.springframework.data.domain.PageRequest;
1214
import org.springframework.data.domain.Sort;
1315
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
1416
import org.springframework.data.relational.core.query.Criteria;
1517
import org.springframework.data.relational.core.query.Query;
1618
import org.springframework.stereotype.Service;
1719
import reactor.core.publisher.Flux;
20+
import reactor.core.publisher.Mono;
1821

1922
@Service
2023
@RequiredArgsConstructor
2124
@Slf4j
2225
public class ClientSearchService {
2326

2427
private final R2dbcEntityTemplate template;
28+
private final ForestClientRepository forestClientRepository;
2529

2630
/**
2731
* 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<ClientPublicViewDto> searchClientByQuery(
104108
)
105109
.doOnNext(client -> log.info("Found client with id {}", client.getClientNumber()));
106110
}
111+
112+
/**
113+
* Constructs a search {@link Criteria} object based on the provided client name, acronym, or
114+
* number.
115+
* This method normalizes input values for case-insensitive searches and validates the client
116+
* number.
117+
*
118+
* @param name the name of the client to search for, or null if not specified.
119+
* @param acronym the acronym of the client to search for, or null if not specified.
120+
* @param number the unique number of the client to search for, or null if not specified.
121+
* @return a {@link Mono} emitting the constructed {@link Criteria} object for the search.
122+
*
123+
* @implNote Input values are transformed to uppercase for case-insensitivity. The client
124+
* number is validated using {@link #checkClientNumber(String)}. Repository results are
125+
* mapped to a search criteria object.
126+
*/
127+
public Mono<Criteria> searchByAcronymNameNumber(String name, String acronym, String number) {
128+
log.info("Searching for clients with name {}, acronym {} or number {}", name, acronym, number);
129+
130+
String searchName = StringUtils.isNotBlank(name) ? name.toUpperCase() : null;
131+
String searchAcronym = StringUtils.isNotBlank(acronym) ? acronym.toUpperCase() : null;
132+
String searchNumber = StringUtils.isNotBlank(number) ? checkClientNumber(number) : null;
133+
134+
return
135+
forestClientRepository
136+
.searchNumberByNameAcronymNumber(
137+
searchName,
138+
searchAcronym,
139+
searchNumber
140+
)
141+
.collectList()
142+
.map(this::searchById);
143+
144+
}
145+
146+
private String checkClientNumber(String clientNumber) {
147+
if(StringUtils.isBlank(clientNumber)) {
148+
return clientNumber;
149+
}
150+
151+
try {
152+
Integer parsed = Integer.parseInt(clientNumber);
153+
return String.format("%08d", parsed);
154+
} catch (NumberFormatException nfe) {
155+
return clientNumber;
156+
}
157+
}
107158
}

src/test/java/ca/bc/gov/api/oracle/legacy/controller/ClientSearchControllerIntegrationTest.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import ca.bc.gov.api.oracle.legacy.AbstractTestContainerIntegrationTest;
44
import ca.bc.gov.api.oracle.legacy.dto.ClientPublicViewDto;
5+
import java.util.Optional;
56
import java.util.stream.Stream;
67
import org.junit.jupiter.api.DisplayName;
8+
import org.junit.jupiter.api.Test;
79
import org.junit.jupiter.params.ParameterizedTest;
810
import org.junit.jupiter.params.provider.Arguments;
911
import org.junit.jupiter.params.provider.MethodSource;
@@ -36,6 +38,39 @@ void shouldSearchClientsById(Integer returnSize, Object[] ids) {
3638
.hasSize(returnSize);
3739
}
3840

41+
@ParameterizedTest
42+
@MethodSource("searchByNameAcronymNumber")
43+
@DisplayName("Search clients by name, acronym, or number")
44+
void shouldSearchByNameAcronymOrNumber(Integer returnSize, String name, String acronym, String number) {
45+
webTestClient
46+
.get()
47+
.uri(uriBuilder -> uriBuilder
48+
.path("/api/clients/search/by")
49+
.queryParamIfPresent("name", Optional.ofNullable(name))
50+
.queryParamIfPresent("acronym", Optional.ofNullable(acronym))
51+
.queryParamIfPresent("number", Optional.ofNullable(number))
52+
.build()
53+
)
54+
.exchange()
55+
.expectStatus().isOk()
56+
.expectBodyList(ClientPublicViewDto.class)
57+
.hasSize(returnSize);
58+
}
59+
60+
61+
private static Stream<Arguments> searchByNameAcronymNumber() {
62+
return Stream.of(
63+
Arguments.of(1, "INDIA",null,null),
64+
Arguments.of(8, null,"SAMPLIBC",null),
65+
Arguments.of(1, null,null,"00000001"),
66+
Arguments.of(1, null,null,"1"),
67+
68+
Arguments.of(0, "XXAABBDA",null,null),
69+
Arguments.of(0, null,"XXAABB",null),
70+
Arguments.of(0, null,null,"12345678")
71+
);
72+
}
73+
3974

4075
private static Stream<Arguments> searchById() {
4176
return Stream.of(

0 commit comments

Comments
 (0)