Symfony Elasticsearch – builder pattern and DTO search criteria object

S

Hi, and welcome to the 4th article devoted to the theme:  “How to work with ElasticSearch using Symfony PHP framework”. Previous article (Part 3: Symfony ElasticSearch – Front Controller and API documentation) is located here. At that article we will speak about how to transform our requests parameters at the DTO criteria object. That will create an abstract layer between the front world and our backend logic and will allow us to be flexible with coming changes from the front side or in case some additional outside integrations. Here we will use the builder pattern.

Builder design pattern
Builder design pattern

We will have a director class. You may treat it as the builder ‘s manager. And HoteSearchCriteria builder that will take url parameters and transform it to a DTO object. Let’s open our search controller and look at realization. All the classes responsible for transformation logic are located at the DependencyInjection -> HotelSearchCriteria folder.

HotelSearchCriteria

Here you see that we define abstract classes for our directors and builders. While looking at abstract criteria builder you can find getCriteria and createCriteria methods which are synonymous of getResult and and buildPart from builder design scheme presented above. Logic is the same – simply naming is adjusted for our requirements.

<?php

namespace App\DependencyInjection\HotelSearchCriteria;

abstract class AbstractCriteriaBuilder
{
    abstract public function getCriteria();

    abstract public function createCriteria();
}

And here is our Director class which accept Builder as dependency injection via constructor

<?php

namespace App\DependencyInjection\HotelSearchCriteria;

abstract class AbstractCriteriaDirector
{
    abstract public function __construct(AbstractCriteriaBuilder $builder);

    abstract public function buildCriteria();

    abstract public function getCriteria();
}

Now lets investigate realization of abstracts. In case HotelSearchCriteriaDirector – there is nothing special, it is rather simple logic. At real practice it can be more complicated and build final output using additional operations e.g getting some additional data form microservices.

<?php

namespace App\DependencyInjection\HotelSearchCriteria;

use App\Entity\HotelSearchCriteria;

class HotelSearchCriteriaDirector extends AbstractCriteriaDirector
{
    /**
     * @var AbstractCriteriaBuilder|null
     */
    private $builder = null;

    /**
     * @param AbstractCriteriaBuilder $builder
     */
    public function __construct(AbstractCriteriaBuilder $builder)
    {
        $this->builder = $builder;
    }

    public function buildCriteria(): void
    {
        $this->builder->createCriteria();
    }

    public function getCriteria(): HotelSearchCriteria
    {
        return $this->builder->getCriteria();
    }
}

At that case builder pattern was realized using abstract classes though it can be done also by using interfaces. That is only the deal of taste. Now lets have a look at our criteria builder realization

<?php

namespace App\DependencyInjection\HotelSearchCriteria;

use App\Entity\HotelSearchCriteria;

class HotelSearchCriteriaUrlBuilder extends AbstractCriteriaBuilder
{
    const PAGE = 'page';
    const SIZE = 'size';

    const HOTEL_NAME = "n";
    const HOTEL_CITY_NAME_EN = "c";
    const CITY_CENTER_LAT = "lat";
    const CITY_CENTER_LNG = "lng";
    const HOTEL_STARTS = "stars";
    const HOTEL_FREE_PLACES = "fpn";
    const HOTEL_AGE = "age";

    /**
     * @var HotelSearchCriteria
     */
    private $criteria;

    /**
     * @var array
     */
    private $data;

    /**
     * @param null|array $data
     */
    public function __construct(?array $data)
    {
        $this->criteria = new HotelSearchCriteria();
        $this->data = $data;
    }

    /**
     * @return void
     */
    public function createCriteria()
    {
        $data = $this->data;
        if (isset($data[self::PAGE]) && is_numeric($data[self::PAGE])) {
            $this->criteria->setPage((int) $data[self::PAGE]);
        }
        if (isset($data[self::SIZE])
            && ($data[self::SIZE] >= HotelSearchCriteria::SIZE_MIN)
            && ($data[self::SIZE] <= HotelSearchCriteria::SIZE_MAX)
        ) {
            $this->criteria->setSize((int) $data[self::SIZE]);
        }

        if (isset($data[self::HOTEL_AGE])) {
            $this->criteria->setHotelAge((int) $data[self::HOTEL_AGE]);
        }

        if (isset($data[self::HOTEL_STARTS])) {
            $this->criteria->setHotelStars((int) $data[self::HOTEL_STARTS]);
        }

        if (isset($data[self::HOTEL_CITY_NAME_EN]) 
            &&  is_string($data[self::HOTEL_CITY_NAME_EN])
        ) {
            $this->criteria->setCityName((string) $data[self::HOTEL_CITY_NAME_EN]);
        }

        if (isset($data[self::HOTEL_NAME]) 
            &&  is_string($data[self::HOTEL_NAME])
        ) {
            $this->criteria->setHotelName((string) $data[self::HOTEL_NAME]);
        }

        if (isset($data[self::HOTEL_FREE_PLACES]) 
            && is_bool($data[self::HOTEL_FREE_PLACES])
        ) {
            $this->criteria->setFreePlacesAtNow((bool) $data[self::HOTEL_FREE_PLACES]);
        }

        if (isset($data[self::CITY_CENTER_LAT])
            && is_numeric($data[self::CITY_CENTER_LAT])
            && isset($data[self::CITY_CENTER_LNG])
            && is_numeric($data[self::CITY_CENTER_LNG])) {
            $this->criteria->setGeoCoordinates([
                'lat' => $data[self::CITY_CENTER_LAT],
                'lon' => $data[self::CITY_CENTER_LNG]
            ]);
        }
    }

    /**
     * @return HotelSearchCriteria
     */
    public function getCriteria(): HotelSearchCriteria
    {
        return $this->criteria;
    }
}

At constructor we a recreating our DTO object which is called HotelSearchCriteria. I created it as doctrine ORM entity which can be persisted at database at e.g for logging and further analytics. Though it also can be done as simple standalone model class. It all depends at your requirements and preferences.

<?php

namespace App\Entity;

class HotelSearchCriteria
{
    const DEFAULT_PAGE = 1;
    const DEFAULT_SIZE = 10;
    const SIZE_MIN = 1;
    const SIZE_MAX = 20;

    /**
     * @var int
     */
    private $page = self::DEFAULT_PAGE;

    /**
     * @var int
     */
    private $size = self::DEFAULT_SIZE;

    /**
     * @ORM\Column(name="free_places_at_now", type="boolean", nullable=true)
     */
    private $freePlacesAtNow = false;

    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(name="hotel_name", type="string", length=500, nullable=false)
     */
    private $hotelName;

    /**
     * @var string
     * @ORM\Column(name="city_name", type="string", length=500, nullable=false)
     */
    private $cityName;

    /**
     * @var int
     *
     * @ORM\Column(name="hotel_age", type="integer", nullable=true)
     */
    private $hotelAge;

    /**
     * @var int
     *
     * @ORM\Column(name="hotel_stars", type="integer", nullable=true)
     */
    private $hotelStars;

    /**
     * @var array
     *
     * @ORM\Column(name="geo_coordinates", type="array", nullable=true, length=1000)
     */
    private $geoCoordinates = [];

    /**
     * @return int
     */
    public function getPage(): int
    {
        return $this->page;
    }

    /**
     * @param int $page
     */
    public function setPage(int $page): void
    {
        $this->page = $page;
    }

    /**
     * @return int
     */
    public function getSize(): int
    {
        return $this->size;
    }

    /**
     * @param int $size
     */
    public function setSize(int $size): void
    {
        $this->size = $size;
    }

    /**
     * @return mixed
     */
    public function getFreePlacesAtNow()
    {
        return $this->freePlacesAtNow;
    }

    /**
     * @param mixed $freePlacesAtNow
     */
    public function setFreePlacesAtNow($freePlacesAtNow): void
    {
        $this->freePlacesAtNow = $freePlacesAtNow;
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @param int $id
     */
    public function setId(int $id): void
    {
        $this->id = $id;
    }

    /**
     * @return string
     */
    public function getHotelName(): string
    {
        return $this->hotelName;
    }

    /**
     * @param string $hotelName
     */
    public function setHotelName(string $hotelName): void
    {
        $this->hotelName = $hotelName;
    }

    /**
     * @return string
     */
    public function getCityName(): string
    {
        return $this->cityName;
    }

    /**
     * @param string $cityName
     */
    public function setCityName(string $cityName): void
    {
        $this->cityName = $cityName;
    }

    /**
     * @return int
     */
    public function getHotelAge(): int
    {
        return $this->hotelAge;
    }

    /**
     * @param int $hotelAge
     */
    public function setHotelAge(int $hotelAge): void
    {
        $this->hotelAge = $hotelAge;
    }

    /**
     * @return int
     */
    public function getHotelStars(): int
    {
        return $this->hotelStars;
    }

    /**
     * @param int $hotelStars
     */
    public function setHotelStars(int $hotelStars): void
    {
        $this->hotelStars = $hotelStars;
    }

    /**
     * @return array
     */
    public function getGeoCoordinates(): array
    {
        return $this->geoCoordinates;
    }

    /**
     * @param array $geoCoordinates
     */
    public function setGeoCoordinates(array $geoCoordinates): void
    {
        $this->geoCoordinates = $geoCoordinates;
    }
}

Now lets return to our HotelSearchCriteriaUrlBuilder -> createCriteria method. It performs the main work and transforms the url parameters we get at request to DTO object. In that case it is rather simple transformations – small validation and map operations. But in real practice create criteria method can much be more complicated.

Now lets see how put together all that puzzle. Lets return to Front Controller – here I will represent only method code without annotations and use blocks (you may find whole code at previous article)

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

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

//        dump($director->getCriteria());die;

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

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

        $resultsView = new SearchResponseView();
        $resultsView->setResults($itemsViewsArray);
        $view = $this->view($resultsView)

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

Please, pay attention at commented dumps. Lets imagine that is would be uncommented and we will run some GET request to our search API point. So we will get next information for debugging that will help to understand building DTO process.

DTO creation process

So, using builder pattern we are creating HotelSearchCriteria DTO object from request. HotelSearchCriteria would represent the interface for our search service, which should know nothing about where search parameters came from and how they were created. Out search service should perform only one responsibility – make a search using DTO parameters.

At the next lecture (Part 5: Symfony ElasticSearch – model data layer) we will go further with creating our search microservice and speak about ONGR Elasticsearch bundle and Elasticsearch model data layer. If you would like to pass all material more fast, then I propose you to view my on-line course at udemy where you will also find full project skeleton. 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. Otherwise, please wait at next articles. Thank you for you attention.


architecture cluster docker elasticsearch flask geo high availability java php programming languages python recommendation systems search systems spring boot symfony