Trier et paginer une liste avec Elasticsearch et Symfony

Share Button

Read the English version

Dans cet article, nous allons trier et paginer notre liste d’articles avec Elasticsearch, Symfony et le WhiteOctoberPageFantaBundle.
C’est la suite de l’article recherche simple avec Elasticsearch, que nous vous recommandons de lire. Cet article part du principe que la recherche est déjà implémentée, et tout le code ne sera pas repris ici.
Paginer avec Symfony est très facile et consiste juste à ajouter des propriétés à notre modèle Search et ajouter le WhiteOctoberPageFantaBundle pour prendre en charge la pagination.

Mettre à jour le modèle

Premièrement, mettons à jour le modèle Search pour ajouter les propriétés qui vont gérer le tri et la pagination (comme dit précédemment, nous ne montrons pas tout le code mais uniquement ce qui change par rapport à l’article précédent. Comme d’habitude, vous pouvez trouver le projet complet sur Github.

<?php

namespace Obtao\BlogBundle\Model;

use Symfony\Component\HttpFoundation\Request;

class ArticleSearch
{
    // autres propriétés

    // un tableau public pour être utilisé comme liste déroulante dans le formulaire
    public static $sortChoices = array(
        'publishedAt desc' => 'Publication date : new to old',
        'publishedAt asc' => 'Publication date : old to new',
    );

    // définit le champ utilisé pour le tri par défaut
    protected $sort = 'publishedAt';

    // définit l'ordre de tri par défaut
    protected $direction = 'desc';

    // une proprité "virtuelle" pour ajouter un champ select
    protected $sortSelect;

    // le numéro de page par défault
    protected $page = 1;

    // le nombre d'items par page
    protected $perPage = 10;

    public function __construct()
    {
        // ancien code

        $this->initSortSelect();
    }

    // autres getters et 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;
    }
}

Le modèle est maintenant à jour et capable de comprendre et prendre en charge les différentes propriétés liées à la pagination et au tri.
Avant de continuer, installez le WhiteOctoberPagerFantaBundle si vous ne l’avez pas déjà fait :

// dans composer.json

// ... autres dépendances
"white-october/pagerfanta-bundle": "dev-master"

et n’oubliez pas de lancer la commande $ ./composer.phar update white-october/pagerfanta-bundle et d’ajouter le bundle dans votre fichier AppKernel.php.

Maintenant, nous devons mettre à jour le SearchRepository pour spécifier à Elasticsearch comment fonctionne notre système de pagination.

Mettre à jour le SearchRepository

Nous allons réorganiser l’ArticleRepository pour prendre en compte la pagination. Nous ajoutons une nouvelle méthode getQueryForSearch pour construire et retourner la requête. Cette méthode sera utilisée par l’ancienne méthode search (qui retourne les résultats sans pagination et l’ArticleController. La requête construite est passée au système de pagination. Voici l’implémentation complète de la classe :

// ...
class ArticleRepository extends Repository
{
    public function getQueryForSearch(ArticleSearch $articleSearch)
    {
        // nous créons une requête pour retourner tous les articles
        // mais si le critère "title" est spécifié, nous l'utilisons
        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();
        }

        // puis nous créons les filtres selon les critères choisis
        $boolQuery = new \Elastica\Query\Bool();
        $boolQuery->addMust($query);

        /*
            Filtre dates
            Nous ajoutons ce filtre seulement si le filtre ispublished n'est pas à "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())
                )
            ));
        }

        // Filtre publié ou non
        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);
    }
}

Mise à jour du FormType

La prochaine étape est de mettre à jour le formulaire de recherche pour ajouter nos champs de tri

// ...
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)
    {
        // la liste de choix "perPage" est codée en dur. Dans un vrai projet, il ne faut pas faire ça
        $perPageChoices = array();
        foreach($this->perPageChoices as $choice){
            $perPageChoices[$choice] = 'Display '.$choice.' items';
        }

        $builder
            // ajout des autres 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) {
                // émule la soumission du sortSelect pour préremplir le champ
                $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);
            })
        ;

Mise à jour du contrôleur

Finalement, nous devons mettre à jour le contrôleur pour ajouter le tri et la pagination.

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

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

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

        // nous passons notre objet de recherche au SearchRepository
        $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(),
        ));
     }

Nous devons aussi éditer la configuration de routing pour mettre à jour la route existante (ajouter une valeur par défaut à “page”) et ajouter une nouvelle route pour la pagination :

# dans 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

Mise à jour des vues

Enfin, nous devons mettre à jour le layout et le template qui affiche la liste.

Dans le layout, nous devons ajouter la librairie jQuery et ajouter le block “javascripts” pour ajouter le code js directement dans les vues.


        {# dans 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%}
    

Maintenant, nous pouvons modifier le template de liste et ajouter un nouveau block Twig qui affichera les boutons de pagination. Nous devons aussi ajouter le code JavaScript qui remplira les champs cachés “sort” et “direction” lorsqu’on sélectionnera une valeur dans le champ “sortSelect” :

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

{% block body %}
    {# code du formulaire #}

    {% 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('');
                    }
                }
                // changer l'ordre de recherche lance une nouvelle recherche
                $('form').submit();
            });
        });
    </script>

{% endblock %}

Share Button

13 thoughts on “Trier et paginer une liste avec Elasticsearch et Symfony

  1. super article, j’arrive parfaitement à le mettre en place. Cependant j’aimerai faire le trie par rapport à une situation géographique. Pour l’instant j’arrive à filtrer mes objet selon une distance. Par exemple un user peut retourné une liste d’entité( qui elle même a une postion), selon une distance qu’il definit.
    Avec ce filtre:
    $geoFilter = new \Elastica\Filter\GeoDistance(‘villesFrance.location’, array(‘lat’ => $activitySearch->getVille()->getLocation()['lat'], ‘lon’ => $activitySearch->getVille()->getLocation()['lon']), $distance);
    $nestedGeoFilter = new \Elastica\Filter\Nested();
    $nestedGeoFilter->setPath(‘villesFrance’);
    $nestedGeoFilter->setFilter($geoFilter);
    $nestedGeoFilter->setQuery($mainQuery);

    $filters->addMust($nestedGeoFilter)

    il n’y a pas moyen de trier ma liste retourné par distance ( du plus près au plus éloigné)?

  2. Bonjour,

    J’essai de mettre en place un système de trie, avec plusieurs champ comme choix dans ce trie, mais avec une seule direction. Il y a quelque chose que je ne comprend pas dans le formtype donné ci-dessus, ou $articleSearch n’est pas utilisé.

    personnellement je n’utilise pas les évènements de formulaire de la même manière.

    voici un exemple d’utilisation:

    $builder->addEventListener(FormEvents::PRE_SUBMIT, array($this, ‘onPreSubmit’));

    protected function addElements(FormInterface $form, Category $category = null ) {

    $rechercher = $form->get(‘rechercher’);

    $form ->remove(‘rechercher’);

    $form->add(‘category’, ‘entity’, array(
    ‘empty_value’ => ‘toutes’,
    ‘required’ => false,
    ‘property’ => ‘category’,
    ‘class’ => ‘HobiizListeActivityBundle:Category’)
    );

    $activities = array();
    if ($category ) {

    $repo = $this->em->getRepository(‘HobiizListeActivityBundle:ListeActivity’);
    $activities = $repo->findByCategory($category, array(‘activity’ => ‘asc’));

    }

    $form->add(‘listeActivity’, ‘entity’, array(
    ‘attr’ => array(‘disabled’ => ‘disabled’),
    ‘required’ => false,
    ‘empty_value’ => ‘choisir une activité’,
    ‘class’ => ‘HobiizListeActivityBundle:ListeActivity’,
    ‘choices’ => $activities,
    ));

    $form ->add($rechercher);
    }

    function onPreSubmit(FormEvent $event) {
    $form = $event->getForm();
    $data = $event->getData();

    $category=$this->em->getRepository(‘HobiizListeActivityBundle:Category’)->find($data['category']);

    $this->addElements($form, $category);
    }

    Pouvez vous m’expliquer votre méthode, merci.

    • Bonjour,

      Pour bien comprendre, il faut également lire l’article “Indexation et recherche simple” car c’est lui qui pose les bases utilisées dans ce billet.
      Notre méthode est relativement simple : nous voulons rechercher des articles via un formulaire de recherche. Les champs du formulaire de recherche ne collent par forcément à notre entité article.
      Par exemple, nous pouvons spécifier une plage de date (date de début et date de fin) alors que notre article ne contient qu’une date de publication.

      Pour répondre à ce besoin, et coller au fonctionnement des formulaires symfony, nous créons tout simplement un object ArticleSearch, qui correspond à la représentation objet de notre formulaire.
      Autrement dit, notre formulaire est bindé à l’object ArticleSearch au lieu d’être bindé à l’objet Article. Cela fait qu’à chaque champ du formulaire (ou presque) correspond une propriété de ArticleSearch.
      C’est cet objet que nous utilisons pour lancer notre recherche et retrouver nos articles.

      j’espère avoir répondu à votre question

      • euh, pas vraiment. Mais c’est de ma faute. Le faite qu’on es crée un objet pour le formulaire et que c’est lui qu’on bind je l’ai parfaitement construit.

        En faite je me suis trompé il s’agit de la ligne que je ne saisit pas:
        $articleForm = $event->getForm();
        que je ne saisi pas puisqu’on n(utilise pas cette variable ($articleForm) par la suite.

        Ensuite dans le modèle je ne comprends pourquoi on met ceci:
        public function handleRequest(Request $request)
        {
        $this->setPage($request->get(‘page’, 1));
        $this->setSort($request->get(‘sort’, ‘publishedAt’));
        $this->setDirection($request->get(‘direction’, ‘desc’));
        }
        a quoi sert le handleRequest, les champ page sort et direction on déjà des valeur par défaut.

        Le troisème point concerne une question que j’avais posé auparavant. Concernant le trie par distance. Vous m’avez renvoyé vers un lien qui me dit de faire quelque chose dans ce genre:
        $query = new \Elastica\Query();
        $query->addSort(
        ['_geo_distance' => [
        'pin.location' => [-70, 40],
        ‘order’ => ‘asc’,
        ‘unit’ => ‘km’]
        ]
        );
        or pin.location sert a renseigner le point de référence pour le trie. Mais a quelle moment récupère on les données des positions de chaque entité à comparer avec ce point.

        Dans mon formulaire j’offre la possibilité de retourner les entité autour d’un point et dans un périmètre donnée avec ce filtre:

        $geoFilter = new \Elastica\Filter\GeoDistance(‘villesFrance.location’, array(‘lat’ => $profilSearch->getVille()->getLocation()['lat'], ‘lon’ => $profilSearch->getVille()->getLocation()['lon']), $distance);

        $nestedGeoFilter = new \Elastica\Filter\Nested();
        $nestedGeoFilter->setPath(‘villesFrance’);
        $nestedGeoFilter->setFilter($geoFilter);
        $nestedGeoFilter->setQuery($mainQuery);

        $filters->addMust($nestedGeoFilter);
        }
        ce filtre marche parfaitement, mais la j’ai trois paramètre ‘villesFrance.location’( c’est la variable de l’entité profil correspond a la position et indexé),
        ‘lat’ => $profilSearch->getVille()->getLocation()['lat'], ‘lon’ => $profilSearch->getVille()->getLocation()['lon'] correspond a la postion de la personne qui cherche et $distance correspond au périmètre choisit dans le formulaire de recherche.

        • Bonjour,

          Tout d’abord, merci car il y avait effectivement un ligne inutile dans le formulaire qui est maintenant supprimée.
          Pour le handleRequest(), c’est un peu vieux mais de mémoire ça sert juste à binder les données soumises par l’utilisateur. En regardant vite fait, on devrait probablement reprendre les valeurs par défaut définies plutôt que de les redéfinir dans cette méthode. Il y a peut être une raison dont je ne me souviens pas, ou pas…

          Pour l’autre question sur la geoloc, je ne comprends pas la question. Ou plutôt, je ne comprends pas ce qui ne fonctionne pas. Le code à l’air à peu près correct. L’objet profilSearch renvoit la latitude et la longitude de référence (en passant par la Ville), et une $distance est spécifiée par l’utilisateur. Du coup, que manque t’il pour que ça fonctionne bien?

          • j’arrive à filtrer mes entités autour d’un point donné. Je veux les trié par odre de distance croissante. J’y arrive de la sorte:
            $result = new \Elastica\Query($query);
            $result->setSort(array(
            “_geo_distance” => array(
            “villesFrance.location” => array(“lon” => $pinLocation['lon'], “lat” => $pinLocation['lat']),
            “order” => “asc”, “unit” => “km”
            ),
            ));

            Mais comment récupérer et afficher cette distance?

          • Logiquement elle est retournée avec chaque résultat. Si vous regardez avec le plugin Head, vous verrez que dans chaque “hit”, il y a un attribut “sort”. Il s’agit de la valeur utilisée pour effectuer le tri, dans votre cas, la distance.

  3. je ne me suis jamais vraiment servi du plugin head. Il retrace toute les requêtes effectuées? ou il faut généré une requête depuis ce plugin? Après ce que je ne comprend pas c’est que depuis mon controller ou dois je récupérer cet attribut.
    $elasticaManager = $this->container->get(‘fos_elastica.manager’);
    $results = $elasticaManager->getRepository(‘HobiizActivityBundle:Activity’)->search($activitySearch); le service $elastica.manager permet de récupérer mes entités doctrine mais je ne vois pas comment récupérer cet attribut “sort”.

    • C’est un tord car le plugin est très pratique. Il permet de faire des recherches (en json) et de voir les résultats. C’est souvent bien plus rapide que de mettre à jour le PHP, tester, voir que c’est faux, corriger etc…
      Dans la debug barre de Symfony, vous pouvez récuperer la requête elasticsearch en json. Copiez là, mettez là dans Head pour voir ce qu’elle sort et si ça correspond à ce que vous faites.
      Pour récupếrer l’attribut sort, c’est peut être $results = $elasticaManager->getRepository(‘HobiizActivityBundle:Activity’)->search($activitySearch)->getResults();. Ensuite, en bouclant dessus, vous devriez pouvoir faire $result->getParam(‘sort’); mais je n’en suis pas sûr à 200%

  4. Bonjour,

    Tout d’abord merci beaucoup pour votre article ainsi que pour vos sources de qualité, tout est très clair en support de la documentation officielle.
    Je suis en train d’essayer de reproduire une query sur ES, en essayant de découpler MySQL d’ES pour avoir des données différentes et complémentaires dans les deux bases. (j’essaie aussi de remplacer la pagination finale par le knp_paginator qui, parait-il, est plus complet).

    En revanche, ES est passé le 17/10/15 en version 2.0, et n’intègre plus les facettes, il les a remplacées par les agrégations.
    J’ai essayé un clone de votre exemple, et que ce soit sous Windows ou sous Linux, au chargement des fixtures, j’ai une exception :
    ————————————————————————————————————————————————-
    > loading [3] Obtao\BlogBundle\DataFixtures\ORM\LoadArticleData
    PHP Fatal error: Wrong parameters for Exception([string $exception [, long $code [, Exception $previous = NULL]]]) in D:\Users\atierant\Development\blog-sandbox\vendor\ruflin\elastica\lib\Elastica\Exception\ResponseException.php on line 34

    Fatal error: Wrong parameters for Exception([string $exception [, long $code [, Exception $previous = NULL]]]) in D:\Users\atierant\Development\blog-sandbox\vendor\ruflin\elastica\lib\Elastica\Exception\ResponseException.php on line 34
    [2015-11-04 12:35:09] php.CRITICAL: Fatal Error: Wrong parameters for Exception([string $exception [, long $code [, Exception $previous = NULL]]]) {“type”:1,”file”:”D:\\Users\\atierant\\Development\\blog-sandbox\\vendor\\ruflin\\elastica\\lib\\Elastica\\Exception\\ResponseException.php”,”line”:34,”level”:-1,”stack”:[]}

    [Symfony\Component\Debug\Exception\FatalErrorException]
    Error: Wrong parameters for Exception([string $exception [, long $code [, Exception $previous = NULL]]])
    ————————————————————————————————————————————————-
    Quand je regarde dans ES :
    ————————————————————————————————————————————————-
    …….source”],”query”:{“bool”:{“must”:[],”must_not”:[],”should”:[{"match_all":{}}]}},”from”:0,”size”:50,”sort”:[],”facets”:{},”version”:true}]]; nested: SearchParseException[failed to parse search source. unknown search element [facets]];

    J’ai tenté la requête à la main dans HEAD, même erreur, j’ai viré le facets.{}, et ça fonctionnait. C’est là que je me suis posé la question de la montée de version 1.7.3 > 2.0.0. J’ai confirmé ça en tentant sur un ES 1.7.3 et effectivement, plus d’erreur de facette.

    Je ne sais pas à qui soulever l’issue, à vous, à FOS ou a Ruflin… Y’a t’il un update du composer à prévoir ?

    En tout cas merci encore.

    • Bonjour,

      Merci pour vos compliments sur les articles, et désolé pour le temps de réponse un peu (beaucoup trop) long.
      Effectivement, nos articles sont basés sur des versions 1.1.x d’elasticsearch et ne sont donc plus trop à jour. L’important étant de conserver une cohérence entre le code et les librairies (notamment FOSElasticaBundle, Elastica et Elasticsearch).

      “”Apparemment”" (oui, avec 2 guillemets), la dernière version de FOSElasticaBundle (v3.1.6 au moment où j’écris : https://packagist.org/packages/friendsofsymfony/elastica-bundle) se base sur la version 2.3.1 d’Elastica (https://packagist.org/packages/ruflin/elastica), elle même nécessitant Elastic 1.7.2.
      Après je ne comprends pas bien si la 2.7.0 d’Elasticsearch n’est pas du tout supportée, ou si seulement certaines fonctionnalités ne marchent pas…

      Pour celà, j’imagine qu’il faudra se rapprocher de Ruflin, ou de rester sur Elasticsearch 1.7.2. Cela dépend de vos besoins

Leave a Reply to Arnaud TIERANT Cancel 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