How to sort and paginate a list with Symfony and Elasticsearch

Share Button

Lire la version française

In this post, we’re going to sort and paginate our articles list with Symfon Elasticsearch and the WhiteOctoberPageFantaBundle. This post follows the post about Indexing and simple search with Elasticsearch and Symfony that you should read. We suppose you have already implemented the search system and all the code will not be shown here.
Paginate with Symfony is as easy as adding some properties to our Search model and ask the WhiteOctoberPageFantaBundle to handle the pagination.

Updating the model

First, let’s update the search model to add some properties to handle the sorting and the pagination (as we said before, we’ll not show the whole code but only the one which has been changed since we wrote the previous post). And as usually, you can find the full project on Github.

<?php

namespace Obtao\BlogBundle\Model;

use Symfony\Component\HttpFoundation\Request;

class ArticleSearch
{
    // other properties

    // a public array to be used as choices list in the form
    public static $sortChoices = array(
        'publishedAt desc' => 'Publication date : new to old',
        'publishedAt asc' => 'Publication date : old to new',
    );

    // define the default field used for the sorting
    protected $sort = 'publishedAt';

    // define the default sort order
    protected $direction = 'desc';

    // a "virtual" property to add a select tag
    protected $sortSelect;

    // the default page number
    protected $page = 1;

    // the default number of items per page
    protected $perPage = 10;

    public function __construct()
    {
        // former code

        $this->initSortSelect();
    }

    // other getters and setters

    public function handleRequest(Request $request)
    {
        $this->setPage($request->get('page', 1));
        $this->setSort($request->get('sort', 'publishedAt'));
        $this->setDirection($request->get('direction', 'desc'));
    }

    public function getPage()
    {
        return $this->page;
    }


    public function setPage($page)
    {
        if ($page != null) {
            $this->page = $page;
        }

        return $this;
    }

    public function getPerPage()
    {
        return $this->perPage;
    }

    public function setPerPage($perPage=null)
    {
        if($perPage != null){
            $this->perPage = $perPage;
        }

        return $this;
    }

    public function setSortSelect($sortSelect)
    {
        if ($sortSelect != null) {
            $this->sortSelect =  $sortSelect;
        }
    }

    public function getSortSelect()
    {
        return $this->sort.' '.$this->direction;
    }

    public function initSortSelect()
    {
        $this->sortSelect = $this->sort.' '.$this->direction;
    }

    public function getSort()
    {
        return $this->sort;
    }

    public function setSort($sort)
    {
        if ($sort != null) {
            $this->sort = $sort;
            $this->initSortSelect();
        }

        return $this;
    }

    public function getDirection()
    {
        return $this->direction;
    }

    public function setDirection($direction)
    {
        if ($direction != null) {
            $this->direction = $direction;
            $this->initSortSelect();
        }

        return $this;
    }
}

The model is now up to date and able to understand and handle the different properties linked to the sorting and pagination.
Before going on, install the WhiteOctoberPagerFantaBundle if you haven’t done it yet :

// in composer.json

// ... other dependencies
"white-october/pagerfanta-bundle": "dev-master"

and do not forget to run the $ ./composer.phar update white-october/pagerfanta-bundle command and to register the bundle in your AppKernel.php file.

Now, we have to update the SearchRepository to tell to Elasticsearch how our pagination system works.

Updating the SearchRepository

We are going to reorganize the ArticleRepository to take the pagination into account. We add a new getQueryForSearch method to build and return the query. This method will be used by the old search method (which returns the results without pagination) and the ArticleController. The built query is passed to the pagination system. Here is the full refactored class :

// ...
class ArticleRepository extends Repository
{
    public function getQueryForSearch(ArticleSearch $articleSearch)
    {
        // we create a query to return all the articles
        // but if the criteria title is specified, we use it
        if ($articleSearch->getTitle() != null && $articleSearch != '') {
            $query = new \Elastica\Query\Match();
            $query->setFieldQuery('article.title', $articleSearch->getTitle());
            $query->setFieldFuzziness('article.title', 0.7);
            $query->setFieldMinimumShouldMatch('article.title', '80%');
        } else {
            $query = new \Elastica\Query\MatchAll();
        }

        // then we create filters depending on the chosen criterias
        $boolQuery = new \Elastica\Query\Bool();
        $boolQuery->addMust($query);

        /*
            Dates filter
            We add this filter only the ispublished filter is not at "false"
        */
        if("false" != $articleSearch->isPublished()
           && null !== $articleSearch->getDateFrom()
           && null !== $articleSearch->getDateTo())
        {
            $boolQuery->addMust(new \Elastica\Query\Range('publishedAt',
                array(
                    'gte' => \Elastica\Util::convertDate($articleSearch->getDateFrom()->getTimestamp()),
                    'lte' => \Elastica\Util::convertDate($articleSearch->getDateTo()->getTimestamp())
                )
            ));
        }

        // Published or not filter
        if($articleSearch->isPublished() !== null){
            $boolQuery->addMust(
                new \Elastica\Query\Terms('published', array($articleSearch->isPublished()))
            );
        }

        $query = new \Elastica\Query($boolQuery);
        $query->setSort(array(
            $articleSearch->getSort() => array(
                'order' => $articleSearch->getDirection()
            )
        ));

        return $query;
    }

    public function searchArticles(ArticleSearch $articleSearch)
    {
        $query = $this->getQueryForSearch($articleSearch);

        return $this->find($query);
    }
}

Update the FormType

The next step is to update the search form to add our specific sort fields

// ...
use Obtao\BlogBundle\Model\ArticleSearch;

class ArticleSearchType extends AbstractType
{
    protected $perPage = 10;
    protected $perPageChoices = array(2,5,10);

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // the perPage choices list is hard coded. In a real project, you won't do like that
        $perPageChoices = array();
        foreach($this->perPageChoices as $choice){
            $perPageChoices[$choice] = 'Display '.$choice.' items';
        }

        $builder
            // add the other types
            ->add('sort', 'hidden', array(
                'required' => false,
            ))
            ->add('direction', 'hidden', array(
                'required' => false,
            ))
            ->add('sortSelect','choice',array(
                'choices' => ArticleSearch::$sortChoices,
            ))
            ->add('perPage', 'choice', array(
                'choices' => $perPageChoices,
            ))
            ->add('search','submit',array(
                'attr' => array(
                    'class' => 'btn btn-primary',
                )
            ))
            ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
                // emulate sortSelect submission to prefill the field
                $articleSearch = $event->getData();

                if(array_key_exists('sort',$articleSearch) && array_key_exists('direction',$articleSearch)){
                    $articleSearch['sortSelect'] = $articleSearch['sort'].' '.$articleSearch['direction'];
                }else{
                    $articleSearch['sortSelect'] = '';
                }

                $event->setData($articleSearch);
            })
        ;

Update the controller

Finally we have to update the controller to add the sorting and the pagination.

    // in Obtao\BlogBundle\Controller\ArticleController.php

    use Pagerfanta\Adapter\ArrayAdapter;
    use Pagerfanta\Pagerfanta;
    // ...

    public function listAction(Request $request)
    {
        $articleSearch = new ArticleSearch();
        $articleSearch->handleRequest($request);
        
        // ...

        // we pass our search object to the search repository
        $results = $this->getSearchRepository()->searchArticles($articleSearch);

        $adapter = new ArrayAdapter($results);
        $pager = new Pagerfanta($adapter);
        $pager->setMaxPerPage($articleSearch->getPerPage());
        $pager->setCurrentPage($page);

        return $this->render('ObtaoBlogBundle:Article:list.html.twig',array(
            'results' => $pager->getCurrentPageResults(),
            'pager' => $pager,
            'articleSearchForm' => $articleSearchForm->createView(),
        ));
     }

We also need to edit the routing configuration to update the existing route (add a “page” default value) and to add a new route for the pagination :

# in src/Obtao/BlogBundle/Resources/config/routing.yml
obtao-article-search:
    pattern:  /article/list
    defaults: { _controller: ObtaoBlogBundle:Article:list , page: 1 }
    requirements:
        _method:  GET

obtao-article-search-paginated:
    pattern:  /article/list/{page}
    defaults: { _controller: ObtaoBlogBundle:Article:list }
    requirements:
        _method:  GET

Update the views

Finally, we need to update the layout and the list template.

In the layout, we need to add jQuery library and to add a “javascripts” block to add js code directly in the views.


        {#in src/Obtao/BlogBundle/Resources/views/layout.html.twig #}
    
        {# ... #}

        <script type="text/javascript" src="https://code.jquery.com/jquery-1.9.1.js"></script>

        {% block javascripts %}{% endblock javascripts%}
    

Now, we can edit the “list” template and add a new Twig block that will display the pagination buttons. We also need to add the javascript code to fill the “sort” and “direction” hidden fields when we select a value in the “sortSelect” field :

{# in src/Obtao/BlogBundle/Resources/views/Article/list.html.twig #}

{% block body %}
    {# form code #}

    {% if pager is not null and pager.haveToPaginate %}
        <div class="pager">
            {{ pagerfanta(pager, 'twitter_bootstrap', {
                'routeName': 'obtao-article-search-paginated',
                'routeParams': app.request.query.all,
                'prev_message': 'Previous',
                'next_message': 'Next'
            }) }}
        </div>
    {% endif %}

    {# results #}
{% endblock %}

{% block javascripts %}

    <script type="text/javascript">
        $(document).ready(function(){
            $('body').find('[name="sortSelect"]').on('change',function(){
                var arr_val = $(this).val().split(' ');
                // check if keys 0 and 1 exist in array
                if (!arr_val || (arr_val.constructor !== Array && arr_val.constructor !== Object)) {
                    return false;
                } else {
                    if ((0 in arr_val) && (1 in arr_val)){
                        $('[name="sort"]').val(arr_val[0]);
                        $('[name="direction"]').val(arr_val[1]);
                        $('[name="page"]').val(1);
                    }else{
                        $('[name="sort"]').val('');
                        $('[name="direction"]').val('');
                    }
                }
                // changing the sort order run a new search
                $('form').submit();
            });
        });
    </script>

{% endblock %}

Share Button

10 thoughts on “How to sort and paginate a list with Symfony and Elasticsearch

  1. Sorry I’m commenting here, but I just want to notice you that the pagination in the english version of blog is not working properly.
    Thanks for your posts

  2. Hello,

    I try your code, but I retrieve always 10 results..

    this function :
    public function getQueryForSearch(ArticleSearch $articleSearch)
    {
    // …

    $query->setSort(array(
    $articleSearch->getSort() => array(
    ‘order’ => $articleSearch->getDirection()
    )
    ));

    return $query;
    }

    replace the search in the previous article ?

    Now you return the $query and not the result of find.

    • Hi,

      Yes, if you use the pagination, you must update your ArticleRepository class. The getQueryForSearch returns the query, the SearchArticles returns the results (for the no pagination system). In the controller, we pass the query to the WhiteOctober pagination. Maybe my post is not clear with this refactoring, I’ll update it. For now, have a look to

      For your number of results problem, there is a property on the ArticleSearch object, you just have to change it.

      • Hi Gregquat,

        I too always received a max of 10 results no more than 10. I downloaded the project here github’s obtao blog-sandbox

        Can you please tell us more, i have changed protected $perPage = 20; in ArticleSearch class but that does not help. Thanks for any pointer!

        • i found the answer: elasticsearch default to 10 max result for performance reason. So i changed that to 3000 for example:

          public function searchTicketscompletes(TicketscompleteSearch $ticketscompleteSearch)
          {
          $query = $this->getQueryForSearch($ticketscompleteSearch);
          $return = $this->find($query, 3000);

          return $return;
          }

    • Hi,
      There is not enough to make a tutorial on this topic. The knp documentation should help you.
      You should also consider using the WhiteOctoberPagerFantaBundle. It is very well done, easier to use and most of time, you do not need the KnpPaginatorBundle as you only need a pagination system. PagerFanta is enough.

  3. Bonjour, et merci beaucoup pour votre tutoriel.

    I am getting stuck when testing the form
    Fatal error: Call to undefined method Alpha\xxxBundle\Controller\xxxController::getSearchRepository()

    I think the problem line is $results = $this->getSearchRepository()->searchCandidates($candidateSearch);

    I am just wondering why this wold be available in the controller? Any ideas?

    Thanks in advance

    Ben

    • :-) Just found the answer at the bottom of ArticleController on github

      protected function getSearchRepository()
      {
      return $this->container
      ->get(‘fos_elastica.manager’)
      ->getRepository(‘ObtaoBlogBundle:Article’)
      ;
      }

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

Protected by WP Anti Spam