Symfony ElasticSearch – Search service and Query builder

S

Hi, and welcome to the 7th and last article devoted to the theme: “How to work with ElasticSearch using Symfony PHP framework”. Previous article (Part 6: Symfony ElasticSearch – indexer command) 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). Please, pay attention at commented line with throw Exception – I will explain later what is for

public function getHotelsSearchAction(Search $search, Request $request): Response
    {
        $requestQueryData = $request->query->all();
        $builder = new HotelSearchCriteriaUrlBuilder($requestQueryData);

        $director = new HotelSearchCriteriaDirector($builder);
        $director->buildCriteria();

        $results = $search->searchRaw($director->getCriteria());

        $itemsViewsArray = [];
        foreach ($results as $item) {
            $itemsViewsArray[] = SimpleSearchItemView::createFromHotel($item);
        }

        $resultsView = new SearchResponseView();
        $resultsView->setResults($itemsViewsArray);
        $view = $this->view($resultsView);
//        throw new Exception('debug via profiler: http://udemy_phpes.com.test/_profiler');

        return $this->handleView($view);
    }

At controller method we are injecting our search service which is defined at the config services file as:

App\DependencyInjection\HotelSearch\Search:
        arguments: ['@App\Document\Hotels', '@app.query_builder']
        public: true

Let’s go to the Search class by itself.

<?php

namespace App\DependencyInjection\HotelSearch;

use App\Entity\HotelSearchCriteria;
use ONGR\ElasticsearchBundle\Result\DocumentIterator;
use ONGR\ElasticsearchBundle\Result\RawIterator;
use ONGR\ElasticsearchDSL\Search as SearchDSL;
use ONGR\ElasticsearchBundle\Service\IndexService;

class Search
{
    /**
     * End points - used for clearing search object
     */
    private static $endpoints = [
        'aggregations' => 'ONGR\ElasticsearchDSL\SearchEndpoint\AggregationsEndpoint',
        'highlight' => 'ONGR\ElasticsearchDSL\SearchEndpoint\HighlightEndpoint',
        'inner_hits' => 'ONGR\ElasticsearchDSL\SearchEndpoint\InnerHitsEndpoint',
        'post_filter' => 'ONGR\ElasticsearchDSL\SearchEndpoint\PostFilterEndpoint',
        'query' => 'ONGR\ElasticsearchDSL\SearchEndpoint\QueryEndpoint',
        'sort' => 'ONGR\ElasticsearchDSL\SearchEndpoint\SortEndpoint',
        'suggest' => 'ONGR\ElasticsearchDSL\SearchEndpoint\SuggestEndpoint'
    ];

    /**
     * @var IndexService
     */
    private $indexService;
    /**
     * @var QueryBuilder
     */
    private $builder;

    public function __construct(IndexService $indexService, QueryBuilder $builder)
    {
        $this->indexService = $indexService;
        $this->builder = $builder;
    }

    /**
     * @param HotelSearchCriteria $criteria
     * @return DocumentIterator
     */
    public function search(HotelSearchCriteria $criteria)
    {
        $this->builder->createQuery($criteria);
        $search = $this->builder->getSearch();

        $results =  $this->indexService->findDocuments($search);
        $this->clear($search);

        return $results;
    }

    /**
     * @param HotelSearchCriteria $criteria
     * @return RawIterator
     */
    public function searchRaw(HotelSearchCriteria $criteria)
    {
        $this->builder->createQuery($criteria);
        $search = $this->builder->getSearch();

        $results =  $this->indexService->findRaw($search);
        $this->clear($search);

        return $results;
    }

    /**
     * Clear all filters.
     *
     * @param SearchDSL $search
     * @return void
     */
    public function clear(SearchDSL $search)
    {
        foreach (self::$endpoints as $type => $value) {
            $search->destroyEndpoint($type);
        }
    }
}

It has 2 dependencies: IndexService – that is a special index manager that is provided by ONGR bundle, and QueryBuilder. Here you can find 2 methods: search and searchRaw. The first one returns DocumentIterator, while second one returns data in array format. Both methods accept the search criteria DTO parameter that is used by the QueryBuilder to build an Elastic final json query. Before we will go further, please pay attention to clear method –  you have to destroy all build criterias after each search because search object can preserve all parameters within one session. That is a very essential notice. Now, please, have a look at DependencyInjection -> HotelSearch folder, where we have our query builder class and filters.

QueryBuilder and filters

Below is the code of QueryBuilder and QueryBuilderInterface

<?php

namespace App\DependencyInjection\HotelSearch;

use App\Entity\HotelSearchCriteria;

interface QueryBuilderInterface
{
    public function createQuery(HotelSearchCriteria $criteria): void;
    public function getSearch(): \ONGR\ElasticsearchDSL\Search;
}
<?php

namespace App\DependencyInjection\HotelSearch;

use App\DependencyInjection\HotelSearch\Filters\CityNameFilter;
use App\DependencyInjection\HotelSearch\Filters\GeoDistanceFilter;
use App\DependencyInjection\HotelSearch\Filters\HotelNameFilter;
use App\DependencyInjection\HotelSearch\Filters\HotelRangeFilter;
use App\Entity\HotelSearchCriteria;
use ONGR\ElasticsearchBundle\Service\IndexService;
use ONGR\ElasticsearchDSL\Query\Compound\BoolQuery;
use ONGR\ElasticsearchDSL\Search;

class QueryBuilder implements QueryBuilderInterface
{
    /**
     * @var Search
     */
    protected $search;

    public function __construct(IndexService $indexService)
    {
        $this->search = $indexService->createSearch();
    }

    /**
     * @param HotelSearchCriteria $criteria
     */
    public function createQuery(HotelSearchCriteria $criteria): void
    {
        $this->setPageOffset($criteria);
        $this->setFields($criteria);
        $this->setFilters($criteria);
        $this->setAggregation($criteria);
        $this->setSorting($criteria);
    }

    /**
     * @return Search
     */
    public function getSearch(): Search
    {
        return $this->search;
    }

    /**
     * @param HotelSearchCriteria $criteria
     */
    public function setFilters(HotelSearchCriteria $criteria)
    {
        if ($criteria->getCityName()) {
            $this->search->addQuery(
                CityNameFilter::createFilter($criteria), BoolQuery::MUST
            );
        }

        if ($criteria->getHotelName()) {
            $this->search->addQuery(
                HotelNameFilter::createFilter($criteria), BoolQuery::MUST
            );
        }

        if ($criteria->getHotelAge()) {
            $this->search->addQuery(
                HotelRangeFilter::createFilter($criteria), BoolQuery::SHOULD
            );
        }

        if ($criteria->getGeoCoordinates()) {
            $this->search->addQuery(
                GeoDistanceFilter::createFilter($criteria), BoolQuery::FILTER
            );
        }
    }

    /**
     * set page offset for document search
     * @param HotelSearchCriteria $criteria
     */
    protected function setPageOffset(HotelSearchCriteria $criteria)
    {
        $startFrom = ($criteria->getPage() - 1) * $criteria->getSize();
        $startFrom = $startFrom <= 0 ? 0 : $startFrom;

        $this->search->setFrom($startFrom);
        $this->search->setSize($criteria->getSize());
    }

    /**
     * @param HotelSearchCriteria $criteria
     */
    protected function setFields(HotelSearchCriteria $criteria)
    {
        //choose fields you want to get from ElasticSearch
    }

    /**
     * @param HotelSearchCriteria $criteria
     */
    protected function setAggregation(HotelSearchCriteria $criteria)
    {
        //add aggregations
    }

    /**
     * @param HotelSearchCriteria $criteria
     */
    protected function 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

Builder interface is realized with AbstractFilter class:

<?php

namespace App\DependencyInjection\HotelSearch\Filters;

use App\Entity\HotelSearchCriteria;
use ONGR\ElasticsearchDSL\BuilderInterface;

abstract class AbstractFilter
{
    abstract public static function createFilter(HotelSearchCriteria $criteria): BuilderInterface;
}

Then 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). Every filter is extended from AbstractFilter:

<?php

namespace App\DependencyInjection\HotelSearch\Filters;

use App\Entity\HotelSearchCriteria;
use ONGR\ElasticsearchDSL\BuilderInterface;
use ONGR\ElasticsearchDSL\Query\Compound\BoolQuery;
use ONGR\ElasticsearchDSL\Query\FullText\MatchQuery;

class CityNameFilter extends AbstractFilter
{
    const FUZZINESS = 2;

    public static function createFilter(HotelSearchCriteria $criteria): BuilderInterface
    {
        $boolQuery = new BoolQuery();
        $boolQuery->add(
            new MatchQuery(
                'hotel.city_name_en', 
                $criteria->getCityName(), 
                ['fuzziness' => self::FUZZINESS]
            ), 
            BoolQuery::SHOULD
        );
        
        $boolQuery->add(
            new MatchQuery(
                'hotel.city_name_en', 
                'London', 
                []
            ), 
            BoolQuery::SHOULD
        );

        return $boolQuery;
    }
}

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?

<?php

namespace App\DependencyInjection\HotelSearch\Filters;

use App\Entity\HotelSearchCriteria;
use ONGR\ElasticsearchDSL\BuilderInterface;
use ONGR\ElasticsearchDSL\Query\Compound\BoolQuery;
use ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery;

class HotelRangeFilter extends AbstractFilter
{
    public static function createFilter(HotelSearchCriteria $criteria): BuilderInterface
    {
        $boolQuery = new BoolQuery();
        $rangeQuery = new RangeQuery(
            'hotel.age', 
            ['from' => $criteria->getHotelAge()]
        );
        
        $boolQuery->add($rangeQuery, BoolQuery::MUST);

        return $boolQuery;
    }
}

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.

Now, lets return to our controller again. Do you remember the commented line we left at controller? Let’s uncomment it and try to call our API point. We will get an exception, as you could guess. But what is interesting, current exception would be registered by symfony framework profiler, so after visiting profiler (link is given at commented line) and clicking at last registered request, you would be able to see also raw ElasticSearch query. It should like like as next one screen:

Symfony profiler, 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