Java Spring Boot and ELASTICSEARCH – Search service and Query builder

J

Hi, and welcome to the 7th and last article devoted to the theme: “How to work with ElasticSearch using Java Spring Boot”. Previous article (Part 6: Spring Boot, ElasticSearch – initial loader, indexing test data) is located here. As a reminder I am providing our architecture scheme:

Search microservice architecture
Search microservice architecture

Finally we get to the end of the road. Now we are ready to investigate the search service. Let’s return to our controller (below I am providing only main method code – whole code you can find at 3d article of current tutorial). Here we have our search service that takes DTO criteria as an argument.

@RequestMapping(value = "/search", method = RequestMethod.GET)
    public ResponseEntity<List<SearchResponseModel>> search(
            @Validated @ModelAttribute SearchRequestModel searchRequestModel
    ) {
        try {
            HotelSearchCriteriaUrlBuilder builder = new HotelSearchCriteriaUrlBuilder(
                    searchRequestModel
            );
            
            HotelSearchCriteriaDirector director = new HotelSearchCriteriaDirector(
                    builder
            );
            
            director.buildCriteria();

            HotelSearchCriteria criteria = director.getCriteria();

            SearchPage<HotelBookingDocument> searchPage = searchService.search(
                    criteria
            );
            
            Iterator<SearchHit<HotelBookingDocument>> iterator = searchPage.iterator();
            List<SearchResponseModel> results = new ArrayList<>();

            while (iterator.hasNext()) {
                HotelBookingDocument hotel = iterator.next().getContent();
                results.add(new SearchResponseModel(
                        hotel.getName(), 
                        hotel.getCityNameEn()
                        )
                );
            }

            return ResponseEntity.ok(results);

        } catch (Throwable t) {
            //implement Exceptions properly - current approach is only for learning purpose
            //you may apply to https://reflectoring.io/spring-boot-exception-handling/ 
            //for help
            List<SearchResponseModel> results = new ArrayList<>();
            SearchResponseModel response = new SearchResponseModel("", "");
            response.setError(t.getMessage());
            response.setStatus(HttpStatus.BAD_REQUEST.value());
            results.add(response);

            return ResponseEntity.ok(results);
        }
    }

 Let’s open  SearchService class.

package com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search;

import com.udemy_sergii_java.spring_boot_es.model.criteria.HotelSearchCriteria;
import com.udemy_sergii_java.spring_boot_es.model.elasticsearch.HotelBookingDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHitSupport;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.SearchPage;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.stereotype.Component;

@Component
public class SearchService {

    @Autowired
    QueryBuilder queryBuilder;

    @Autowired
    ElasticsearchOperations operations;

    public SearchPage search(HotelSearchCriteria criteria) {
        queryBuilder.createQuery(criteria);
        NativeSearchQuery search = queryBuilder.getSearch();

        SearchHits<HotelBookingDocument> searchHits = operations.search(
                search,
                HotelBookingDocument.class
        );
        SearchPage<HotelBookingDocument> searchPage = SearchHitSupport.searchPageFor(
                searchHits,
                queryBuilder.getPageRequest()
        );

        return searchPage;
    }

    public String getRawJsonQuery(HotelSearchCriteria criteria) {
        queryBuilder.createQuery(criteria);
        NativeSearchQuery search = queryBuilder.getSearch();

        return search.getQuery().toString();
    }
}

As you can see it has rather simple structure in our case. We inject here ElasticsearchOperations and QueryBuilder as dependencies using auto wiring. At search method we pass DTO criteria object to query builder which creates according Elasticsearch search query, then we run according query using operations.search method and finally we transform results to SearchPage interface. Now, please, have a look at dependencies -> hotel_search folder, where we have our query builder class and filters.

QueryBuilder and filters

QueryBuilder class implements QueryBuilderInterface:

package com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search;

import com.udemy_sergii_java.spring_boot_es.model.criteria.HotelSearchCriteria;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;

public interface QueryBuilderInterface {
    void createQuery(HotelSearchCriteria criteria);

    NativeSearchQuery getSearch();
}

Here is code of QueryBuilder class by itself, where the main magic happens:

package com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search;

import com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search.filters.CityNameFilter;
import com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search.filters.GeoDistanceFilter;
import com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search.filters.HotelAgeFilter;
import com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search.filters.HotelNameFilter;
import com.udemy_sergii_java.spring_boot_es.model.criteria.HotelSearchCriteria;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.stereotype.Component;

@Component
public class QueryBuilder implements QueryBuilderInterface {
    private NativeSearchQueryBuilder searchQueryBuilder;
    private PageRequest pageRequest;

    public QueryBuilder() {
        this.searchQueryBuilder = new NativeSearchQueryBuilder();
    }

    @Override
    public void createQuery(HotelSearchCriteria criteria) {
        this.setPageOffset(criteria);
        this.setFilters(criteria);
        this.setAggregation(criteria);
        this.setSorting(criteria);
        this.setFields(criteria);
    }

    @Override
    public NativeSearchQuery getSearch() {
        return this.searchQueryBuilder.build();
    }

    public PageRequest getPageRequest() {
        return this.pageRequest;
    }

    protected void setFilters(HotelSearchCriteria criteria) {
        BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();

        if (!criteria.getHotelName().isEmpty()) {
            boolQueryBuilder.must(HotelNameFilter.createFilter(criteria));
        }

        if (!criteria.getCityName().isEmpty()) {
            boolQueryBuilder.must(CityNameFilter.createFilter(criteria));
        }

        if (criteria.getHotelAge() >= 0) {
            boolQueryBuilder.should(HotelAgeFilter.createFilter(criteria));
        }

        boolQueryBuilder.filter(GeoDistanceFilter.createFilter(criteria));

        this.searchQueryBuilder.withQuery(boolQueryBuilder);
    }

    protected void setPageOffset(HotelSearchCriteria criteria) {
        this.pageRequest = PageRequest.of(criteria.getPage(), criteria.getSize());
        this.searchQueryBuilder.withPageable(this.pageRequest);
    }

    protected void setFields(HotelSearchCriteria criteria) {
        //choose fields you want to get from ElasticSearch
    }

    protected void setAggregation(HotelSearchCriteria criteria) {
        //add aggregations
    }

    protected void setSorting(HotelSearchCriteria criteria) {
        //add sorting
    }
}

The main method here is createQuery, where we set pagination and sorting; fields we want to get; search filters and aggregations. My main goal is to show you Filter Design Pattern, so setFields, setAggregation and setSorting methods are not realized – that is a small home task for you 🙂 Let’s concentrate at setFilters method now, where we adding all necessary filters gradually using Filter Design Pattern:

Filter design pattern
Filter design pattern

We have list of filters that are added dependently if according property is present at criteria DTO object. Below is the example of 2 realizations, that would be enough to understand general idea (if you want to get all realization, then please refer to my course, where you will get the access to whole project code).

package com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search.filters;

import com.udemy_sergii_java.spring_boot_es.model.criteria.HotelSearchCriteria;
import org.elasticsearch.common.unit.Fuzziness;
import org.elasticsearch.index.query.AbstractQueryBuilder;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.MatchQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;

public class CityNameFilter {
    public static AbstractQueryBuilder createFilter(HotelSearchCriteria criteria) {
        MatchQueryBuilder matchQueryFirst = QueryBuilders.matchQuery(
        "cityNameEn", 
         criteria.getCityName()
        )
        .fuzziness(Fuzziness.TWO);
        
        MatchQueryBuilder matchQuerySecond = QueryBuilders.matchQuery(
                "cityNameEn", 
                "London"
        );

        BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery()
                .should(matchQueryFirst)
                .should(matchQuerySecond);

        return boolQueryBuilder;
    }
}

Please, pay attention at fuzziness property above. That is a rather interesting feature that means maximum edit distance allowed for matching. In practice it means that we allow 2 typos in word – and EaslticSearch will still allow us to perform correct search – cool, isn’t it? One more filter example:

package com.udemy_sergii_java.spring_boot_es.dependencies.hotel_search.filters;

import com.udemy_sergii_java.spring_boot_es.model.criteria.HotelSearchCriteria;
import org.elasticsearch.index.query.AbstractQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.index.query.RangeQueryBuilder;

public class HotelAgeFilter {
    public static AbstractQueryBuilder createFilter(HotelSearchCriteria criteria) {
        RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("age")
                .from(criteria.getHotelAge())
                .includeLower(true);

        return rangeQueryBuilder;
    }
}

So we are constructing our filters to must, should, filter conditions at setFilters method. Filter design pattern is very useful at production, where you can have even hundreds of different filters. In that case it is not so easy to organize your code properly and to keep it in readable format. Current pattern helps a lot in resolving all such a problems. But it still will not resolve all your problems :). Imagine that you can have 30-50 such filters. It is rather easy to make some mistakes at composing complicated structures. You will need to constantly debug your final json query. Which way can we do it? You may configure a debugger at your code editor. That is a rather good solution. But as many people as many IDE and configurations. It would be difficult for me to show how to configure debugging at least for the most popular code editors. Fortunately, a rather universal solution exists – and that is using NativeSearchQuery by itself – let’s return to our SearchService class -> getRawJsonQuery method

public String getRawJsonQuery(HotelSearchCriteria criteria) {
        queryBuilder.createQuery(criteria);
        NativeSearchQuery search = queryBuilder.getSearch();

        return search.getQuery().toString();
 }

So, here we are getting search object built by our queryBuilder and transliterate it to raw json. At main application I created special API point, that will help us to provide debugging operations at final raw query, which is sent to ElasticSearch engine (where getRawJsonQuery method is used)

@RequestMapping(value = "/show-raw-json", method = RequestMethod.GET)
    public String showRawJson(@Validated @ModelAttribute SearchRequestModel searchRequestModel) {
        try {
            HotelSearchCriteriaUrlBuilder builder = new HotelSearchCriteriaUrlBuilder(
                    searchRequestModel
            );
            
            HotelSearchCriteriaDirector director = new HotelSearchCriteriaDirector(
                    builder
            );
            
            director.buildCriteria();

            HotelSearchCriteria criteria = director.getCriteria();
            String rawJsonQuery = searchService.getRawJsonQuery(criteria);

            return rawJsonQuery;

        } catch (Throwable t) {
            //implement Exceptions properly - current approach is only for learning purpose
            //you may apply to https://reflectoring.io/spring-boot-exception-handling/ 
            // for help
            return t.getMessage();
        }
    }

After running according API point and switching to view page source you have to see raw json elasticsearch query – something similar to screen below:

ElasticSearch json raw query

To be completely aware of what is going on here, you have to understand the basics – how ElasticSearch works, how to read low level json queries. If you know basics – you can easily read that query at lower level, you can understand what was done wrong at upper level in case of some errors or unexpected results. You should understand that any programming language – that is only a high level wrapper – but basics stays always the same. When you understand fundamentals – than you can realize any logic within any programming language. If you would like to get deep knowledge from ElasticSearch fundamentals – then, please visit my course at udemy. Below is the link to the course. As the reader of that blog you are also getting possibility to use coupon for the best possible low price. Thank you for you attention. Hope that you liked current part.  I also hope that you could get out for yourself a lot of interesting and useful information within the whole tutorial. Thank you for being with me whole that time and welcome to my course if you want to get know more.


architecture AWS cluster cyber-security devops devops-basics docker elasticsearch flask geo high availability java machine learning opensearch php programming languages python recommendation systems search systems spring boot symfony