Commit e2de48e2 authored by Jean-Baptiste Nizet's avatar Jean-Baptiste Nizet
Browse files

feat(backend): allow refining the search using facets

parent 1d8e0e60
......@@ -18,5 +18,6 @@ public interface GeneticResourceDaoCustom {
*/
AggregatedPage<GeneticResource> search(String query,
boolean aggregate,
SearchRefinements refinements,
Pageable page);
}
package fr.inra.urgi.rare.dao;
import static org.elasticsearch.index.query.QueryBuilders.boolQuery;
import static org.elasticsearch.index.query.QueryBuilders.multiMatchQuery;
import static org.elasticsearch.index.query.QueryBuilders.termsQuery;
import java.util.Collections;
import java.util.Set;
......@@ -8,6 +10,7 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import fr.inra.urgi.rare.domain.GeneticResource;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.core.ElasticsearchTemplate;
......@@ -52,9 +55,17 @@ public class GeneticResourceDaoImpl implements GeneticResourceDaoCustom {
@Override
public AggregatedPage<GeneticResource> search(String query,
boolean aggregate,
SearchRefinements refinements,
Pageable page) {
BoolQueryBuilder boolQueryBuilder = boolQuery()
.must(multiMatchQuery(query, SEARCHABLE_FIELDS.toArray(new String[0])));
for (RareAggregation term : refinements.getTerms()) {
boolQueryBuilder.must(termsQuery(term.getField(), refinements.getRefinementsForTerm(term)));
}
NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder()
.withQuery(multiMatchQuery(query, SEARCHABLE_FIELDS.toArray(new String[0])))
.withQuery(boolQueryBuilder)
.withPageable(page);
if (aggregate) {
......@@ -63,6 +74,7 @@ public class GeneticResourceDaoImpl implements GeneticResourceDaoCustom {
.field(rareAggregation.getField())
.size(MAX_BUCKETS)));
}
return elasticsearchTemplate.queryForPage(builder.build(), GeneticResource.class);
}
}
package fr.inra.urgi.rare.dao;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* The refinements, based on aggregated values, to be added to a search
* @author JB Nizet
*/
public final class SearchRefinements {
public static final SearchRefinements EMPTY = SearchRefinements.builder().build();
private final Map<RareAggregation, Set<String>> termRefinements;
private SearchRefinements(Builder builder) {
this.termRefinements = new HashMap<>(builder.termRefinements);
}
public Set<RareAggregation> getTerms() {
return termRefinements.keySet();
}
public Set<String> getRefinementsForTerm(RareAggregation rareAggregation) {
return termRefinements.get(rareAggregation);
}
public static Builder builder() {
return new Builder();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
SearchRefinements that = (SearchRefinements) o;
return Objects.equals(termRefinements, that.termRefinements);
}
@Override
public int hashCode() {
return Objects.hash(termRefinements);
}
@Override
public String toString() {
return "SearchRefinements{" +
"termRefinements=" + termRefinements +
'}';
}
public static final class Builder {
private final Map<RareAggregation, Set<String>> termRefinements = new HashMap<>();
public Builder withTerm(RareAggregation term, Collection<String> values) {
this.termRefinements.put(term, Collections.unmodifiableSet(new HashSet<>(values)));
return this;
}
public SearchRefinements build() {
return new SearchRefinements(this);
}
}
}
package fr.inra.urgi.rare.search;
import java.util.List;
import java.util.Optional;
import fr.inra.urgi.rare.dao.GeneticResourceDao;
import fr.inra.urgi.rare.dao.RareAggregation;
import fr.inra.urgi.rare.dao.SearchRefinements;
import fr.inra.urgi.rare.domain.GeneticResource;
import fr.inra.urgi.rare.dto.AggregatedPageDTO;
import org.springframework.data.domain.PageRequest;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
......@@ -26,14 +30,41 @@ public class SearchController {
this.geneticResourceDao = geneticResourceDao;
}
/**
* Searches for the given query, and returns an aggregated page of results
* @param query the query (mandatory parameter)
* @param page the requested page number, starting at 0. Defaults to 0 if not passed.
* @param agg if true, the aggregated page's aggregations will be a non-empty array containing all the terms
* aggregations allowing to refine the query. If false or omitted, the aggregations won't be loaded and the
* aggregations array in the result will be empty
* @param parameters all the parameters, containing the refinements based on the aggregations. The names
* of the other parameters are the names of the agregations, and the values are one of the values for that
* aggregation.
*
* @see fr.inra.urgi.rare.dao.RareAggregation
*/
@GetMapping
public AggregatedPageDTO<GeneticResource> search(@RequestParam("query") String query,
@RequestParam("agg") Optional<Boolean> agg,
@RequestParam("page") Optional<Integer> page) {
@RequestParam("page") Optional<Integer> page,
@RequestParam MultiValueMap<String, String> parameters) {
boolean aggregate = agg.orElse(false);
return AggregatedPageDTO.fromPage(geneticResourceDao.search(query,
aggregate,
createRefinementsFromParameters(parameters),
PageRequest.of(page.orElse(0), PAGE_SIZE)));
}
private SearchRefinements createRefinementsFromParameters(MultiValueMap<String, String> parameters) {
SearchRefinements.Builder builder = SearchRefinements.builder();
for (RareAggregation rareAggregation : RareAggregation.values()) {
List<String> parameterValues = parameters.get(rareAggregation.getName());
if (parameterValues != null && !parameterValues.isEmpty()) {
builder.withTerm(rareAggregation, parameterValues);
}
}
return builder.build();
}
}
......@@ -12,6 +12,7 @@ import fr.inra.urgi.rare.domain.GeneticResourceBuilder;
import org.elasticsearch.search.aggregations.bucket.terms.Terms;
import org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
......@@ -143,7 +144,10 @@ class GeneticResourceDaoTest {
GeneticResource geneticResource = new GeneticResourceBuilder().build();
geneticResourceDao.save(geneticResource);
assertThat(geneticResourceDao.search(geneticResource.getId(), false, firstPage).getContent()).isEmpty();
assertThat(geneticResourceDao.search(geneticResource.getId(),
false,
SearchRefinements.EMPTY,
firstPage).getContent()).isEmpty();
}
@Test
......@@ -152,7 +156,10 @@ class GeneticResourceDaoTest {
new GeneticResourceBuilder().withDataURL("foo bar baz").withPortalURL("foo bar baz").build();
geneticResourceDao.save(geneticResource);
assertThat(geneticResourceDao.search("bar", false, firstPage).getContent()).isEmpty();
assertThat(geneticResourceDao.search("bar",
false,
SearchRefinements.EMPTY,
firstPage).getContent()).isEmpty();
}
private void shouldSearch(BiConsumer<GeneticResourceBuilder, String> config) {
......@@ -162,11 +169,12 @@ class GeneticResourceDaoTest {
geneticResourceDao.save(geneticResource);
AggregatedPage<GeneticResource> result = geneticResourceDao.search("bar", false, firstPage);
AggregatedPage<GeneticResource> result =
geneticResourceDao.search("bar", false, SearchRefinements.EMPTY, firstPage);
assertThat(result.getContent()).hasSize(1);
assertThat(result.getAggregations()).isNull();
result = geneticResourceDao.search("bing", false, firstPage);
result = geneticResourceDao.search("bing", false, SearchRefinements.EMPTY, firstPage);
assertThat(result.getContent()).isEmpty();
}
......@@ -192,7 +200,8 @@ class GeneticResourceDaoTest {
geneticResourceDao.saveAll(Arrays.asList(geneticResource1, geneticResource2));
AggregatedPage<GeneticResource> result = geneticResourceDao.search("foo", true, firstPage);
AggregatedPage<GeneticResource> result =
geneticResourceDao.search("foo", true, SearchRefinements.EMPTY, firstPage);
assertThat(result.getContent()).hasSize(2);
Terms domain = result.getAggregations().get(RareAggregation.DOMAIN.getName());
......@@ -215,5 +224,84 @@ class GeneticResourceDaoTest {
assertThat(countryOfOrigin.getBuckets()).extracting(Bucket::getKeyAsString).containsExactly("France");
assertThat(countryOfOrigin.getBuckets()).extracting(Bucket::getDocCount).containsExactly(2L);
}
@Nested
class RefinementTest {
@BeforeEach
public void prepare() {
GeneticResource geneticResource1 = new GeneticResourceBuilder()
.withId("r1")
.withName("foo")
.withDomain("Plantae")
.withBiotopeType(Arrays.asList("Biotope", "Human host"))
.withMaterialType(Arrays.asList("Specimen", "DNA"))
.withCountryOfOrigin("France")
.withDescription("hello world")
.build();
GeneticResource geneticResource2 = new GeneticResourceBuilder()
.withId("r2")
.withName("bar foo")
.withDomain("Fungi")
.withBiotopeType(Arrays.asList("Biotope"))
.withMaterialType(Arrays.asList("DNA"))
.withCountryOfOrigin("France")
.withDescription("hello world")
.build();
geneticResourceDao.saveAll(Arrays.asList(geneticResource1, geneticResource2));
}
@Test
public void shouldApplyRefinementsOnSingleTermWithOr() {
SearchRefinements refinements =
SearchRefinements.builder()
.withTerm(RareAggregation.DOMAIN, Arrays.asList("unexisting", "Plantae"))
.build();
AggregatedPage<GeneticResource> result =
geneticResourceDao.search("hello", false, refinements, firstPage);
assertThat(result.getContent()).extracting(GeneticResource::getId).containsOnly("r1");
refinements = SearchRefinements.builder()
.withTerm(RareAggregation.DOMAIN, Arrays.asList("unexisting", "Fungi"))
.build();
result =
geneticResourceDao.search("hello", false, refinements, firstPage);
assertThat(result.getContent()).extracting(GeneticResource::getId).containsOnly("r2");
refinements = SearchRefinements.builder()
.withTerm(RareAggregation.DOMAIN, Arrays.asList("unexisting"))
.build();
result =
geneticResourceDao.search("hello", false, refinements, firstPage);
assertThat(result.getContent()).extracting(GeneticResource::getId).isEmpty();
}
@Test
public void shouldApplyRefinementsOnMultipleTermsWithAnd() {
SearchRefinements refinements =
SearchRefinements.builder()
.withTerm(RareAggregation.DOMAIN, Arrays.asList("unexisting", "Fungi"))
.withTerm(RareAggregation.BIOTOPE, Arrays.asList("unexisting", "Biotope"))
.build();
AggregatedPage<GeneticResource> result =
geneticResourceDao.search("hello", false, refinements, firstPage);
assertThat(result.getContent()).extracting(GeneticResource::getId).containsOnly("r2");
refinements =
SearchRefinements.builder()
.withTerm(RareAggregation.DOMAIN, Arrays.asList("unexisting", "Fungi"))
.withTerm(RareAggregation.BIOTOPE, Arrays.asList("Human host"))
.build();
result =
geneticResourceDao.search("hello", false, refinements, firstPage);
assertThat(result.getContent()).isEmpty();
}
}
}
......@@ -10,6 +10,8 @@ import java.util.Collections;
import fr.inra.urgi.rare.config.SecurityConfig;
import fr.inra.urgi.rare.dao.GeneticResourceDao;
import fr.inra.urgi.rare.dao.RareAggregation;
import fr.inra.urgi.rare.dao.SearchRefinements;
import fr.inra.urgi.rare.domain.GeneticResource;
import fr.inra.urgi.rare.domain.GeneticResourceBuilder;
import org.elasticsearch.search.aggregations.Aggregations;
......@@ -48,7 +50,7 @@ class SearchControllerTest {
PageRequest pageRequest = PageRequest.of(0, SearchController.PAGE_SIZE);
String query = "pauca";
when(mockGeneticResourceDao.search(query, false, pageRequest))
when(mockGeneticResourceDao.search(query, false, SearchRefinements.EMPTY, pageRequest))
.thenReturn(new AggregatedPageImpl<>(Arrays.asList(resource), pageRequest, 1));
mockMvc.perform(get("/api/genetic-resources").param("query", query))
......@@ -71,7 +73,7 @@ class SearchControllerTest {
PageRequest pageRequest = PageRequest.of(0, SearchController.PAGE_SIZE);
String query = "pauca";
when(mockGeneticResourceDao.search(query, true, pageRequest))
when(mockGeneticResourceDao.search(query, true, SearchRefinements.EMPTY, pageRequest))
.thenReturn(new AggregatedPageImpl<>(
Arrays.asList(resource),
pageRequest,
......@@ -101,4 +103,28 @@ class SearchControllerTest {
.andExpect(jsonPath("$.aggregations[0].buckets[1].documentCount").value(2))
.andExpect(jsonPath("$.aggregations[1].name").value("countryOfOrigin"));
}
@Test
void shouldSearchWIthRefinements() throws Exception {
PageRequest pageRequest = PageRequest.of(1, SearchController.PAGE_SIZE);
String query = "pauca";
SearchRefinements expectedRefinements =
SearchRefinements.builder()
.withTerm(RareAggregation.DOMAIN, Arrays.asList("d1"))
.withTerm(RareAggregation.BIOTOPE, Arrays.asList("b1", "b2"))
.withTerm(RareAggregation.MATERIAL, Arrays.asList("m1"))
.build();
when(mockGeneticResourceDao.search(query, false, expectedRefinements, pageRequest))
.thenReturn(new AggregatedPageImpl<>(Collections.emptyList(), pageRequest, 1));
mockMvc.perform(get("/api/genetic-resources")
.param("query", query)
.param("page", "1")
.param(RareAggregation.DOMAIN.getName(), "d1")
.param(RareAggregation.BIOTOPE.getName(), "b2", "b1")
.param(RareAggregation.MATERIAL.getName(), "m1"))
.andExpect(status().isOk());
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment