Faire des filtres de listing grace aux aggrégations Elasticsearch

Jean-Pascal

Jean-Pascal, Architecte web 17 janvier 2018

Les attentes concernant les filtres de listing

Qu'est-ce qu'on attend d'un filtre de listing ?
A mon humble avis, les caractéristiques d'un filtre de listing réussi sont les suivantes :

  • Permettre de réduire le nombre de résultats affichés ;
  • Ne pas permettre d'arriver à un nombre de résultats nul ;

C'est cette deuxième caractéristique qui nous interesse ici.

Ben pourquoi ?
Parce qu'on ne peut pas bêtement proposer toutes les valeurs possibles pour chaque filtre : les options de filtres disponibles doivent être elles-même filtrées par les résultats obtenus. En fait, pour chaque valeur disponible, il faut qu'on s'assure qu'il existe des résultats si on venait à choisir cette option, en plus des filtres existants.

La solution pour combler ces attentes : Elasticsearch et ses aggregations

Ok, comment on fait ça ?
Merci, interlocuteur fictif pour ces questions pertinentes me permettant de développer mon propos.
Il se trouve qu'Elasticsearch propose quelque chose de tout à fait adapté : les aggregations.
Comme vu dans la doc, les aggregations peuvent se présenter sous deux formes principales :

  • Metric : Permet de récupérer un scalaire (une somme, un max, une moyenne)
  • Bucket : Permet de récupérer un sous ensemble des résultats de la recherche, filtré par une variable de recherche.

Il dit qu'il a plus de genou
Ok je t'explique comment ça fonctionne :
On va prendre ici l'exemple du bucket Term, qui permet de grouper les résultats d'une requète sur toutes les valeurs possible d'un attribut ES.
Supposons le mapping suivant:

mappings:

    product:

        properties:

            color:

                type: string

                index: not_analyzed # Pour ne prendre que les valeurs exactes


si on ajoute une aggregation term sur color

{

    "aggs" : {

        "colors" : {

            "terms" : { "field" : "color" }

        }

    }

}

On obtiendra dans la réponse les infos suivantes

{

    ...

    "aggregations" : {

        "color" : {

            "doc_count_error_upper_bound": 0, 

            "sum_other_doc_count": 0, 

            "buckets" : [ 

                {

                    "key" : "rouge",

                    "doc_count" : 55

                },

                {

                    "key" : "bleu",

                    "doc_count" : 23

                }

            ]

        }

    }

}

cf la doc Elasticsearch
Ce qui permet dans les faits de connaitre, pour les résultats de la recherches, quelles sont les valeurs de couleurs concernant les résultats de la recherche; ou, en d'autre termes, les valeurs de color sur lesquelles on peut filtrer les résultats de recherche qu'on a deja (avec un bonus, une estimation du nombre de résultats correspondant à chaque valeur).
Ça tombe bien, c'est exactement ce que je voulais faire.

Application pratique avec Symfony et le novaway/elasticsearch-client


On veut en fait faire plusieurs choses :

  • Obtenir un ensemble de résultats, éventuellement filtrés (ici, par soumission d'un Form)
  • Construire le nouveau formulaire de filtre

On va procéder en trois temps :
D'abord faire une requête non filtrée, afin de récupérer toutes les valeurs possible pour tous les filtres, et permettre de valider les valeurs soumises
Soumettre le formulaire, ce qui nous permettra d'obtenir une nouvelle requète, filtrée cette fois
Récupérer les résultats, et reconstruire le nouveau formulaire qui permettra de recréer les filtres permettant d'affiner encore le listing
Supposons la classe stockant les valeurs de filtres:


class SearchFilterData

{

    /** @var  string */

    protected $color;

    /** @var  string */

    protected $material;

    /** @var  int */

    protected $minPrice;

    /** @var  int */

    protected $maxPrice;

}

Et une QueryBuilderProvider

class QueryBuilderProvider

{

    const MIN_SCORE = 0.05;

    const OFFSET = 0;

    const LIMIT = 10;

    

    public function provideQueryBuilder(SearchFilterData $data, $page = 1)

    {

        $queryBuilder = new QueryBuilder(

            self::OFFSET + ($page - 1) * self::LIMIT,

            self::LIMIT,

            self::MIN_SCORE

        );

        // on ajoute les aggregations sur les deux termes

        $queryBuilder

            ->addAggregation(new Aggregation('colors', 'terms', 'color'))

            ->addAggregation(new Aggregation('materials', 'terms', 'material'))

            

        if ($data->getColor()) {

            // si une couleur est renseignée dans le SearchFilterData, ajouter le filtre correspondant

            $queryBuilder->addFilter(new TermFilter('color', $data->getColor()));

        }

        

        if ($data->getMaterial()) {

            // si une matière est renseignée dans le SearchFilterData, ajouter le filtre correspondant

            $queryBuilder->addFilter(new TermFilter('color', $data->getMaterial()));

        }

    }

}

Et enfin le Form

class SearchFilterType extends AbstractType

{

    public function buildForm(FormBuilderInterface $builder, array $options)

    {

        $aggregationChoicesKeys = [

            'brand' => 'brands',

            'material' => 'materials',

        ];

        foreach ($aggregationChoicesKeys as $field => $key) {

            if (

                !empty($options['aggregations'][$key])

            ) {

                $builder

                    ->add($field, ChoiceType::class, [

                        'required' => false,

                        'choices' => $options['aggregations'][$key]

                    ])

                ;

            }

        }

    }

    public function configureOptions(OptionsResolver $resolver)     {         $resolver->setRequired([            'aggregations'         ]);         $resolver->setDefaults([

            'data_class' => SearchFilterData::class

        ]);

    }

}

Bon maintenant qu'on a les outils, on peut enfin construire une action :

public function listingAction(Request $request)

{

    // on créé la requete, sans filtres

    

    $filterData = new SearchFilterData();

    /** @var QueryBuilderProvider $this->queryBuilderProvider */

    $queryBuilder = $this->queryBuilderProvider->provideQueryBuilder($filterData);

       

    // puis on récupère les resultats, avec les aggregations, qui comportent toutes les valeurs existantes de material et color 

    

    /** @var Novaway\ElasticsearchClient\QueryExecutor $this->queryExecutor */

    $results = $this->queryExecutor->execute(

        $queryBuilder->getQueryBody(),

        'my_index'

    ));

  

    // On peuple le formulaire, en proposant comme choix de filtres toutes les valeurs possibles     
     $form = $this->createForm(SearchFilterType::class, $filterData, [
         'aggregations' => $this->extractAggregationsChoices($results->aggregations())     
     ]);
     $form->handleRequest($request);
     if ($form->isSubmitted() && $form->isValid()) {
         // à ce moment, le $filterData contient les valeurs soumises.
         // Le form ayant été créé avec toutes les valeurs possibles, les valeurs postées peuvent être validées                  
         // on recommence l'opération, cette fois avec les filtres effectif         
         /** @var QueryBuilderProvider $this->queryBuilderProvider */         
         $queryBuilder = $this->queryBuilderProvider->provideQueryBuilder($filterData);                      
        /** @var Novaway\ElasticsearchClient\QueryExecutor $this->queryExecutor */         
        $results = $this->queryExecutor->execute($queryBuilder->getQueryBody(),'my_index'));
         // les resultats, et les aggregations, tiennent compte cette fois des resultats filtrés         
         // si dans les resultats filtrés, une seule couleur est disponible,          
         // alors les choices passés à $form->get('color') ne comprendront que cette couleur :                   
         $form = $this->createForm(SearchFilterType::class, $filterData, [             
              'aggregations' => $this->extractAggregationsChoices($results->aggregations())         
         ]);         
         // render template     
      }     
      // render template 
}

protected function extractAggregationsChoices(array $aggregations) {

  $bucketKeys = ['colors', 'materials'];

  $results = [];

  foreach ($bucketKeys as $key) {

    // pour chaque clef de bucket il faut récupérer les valeurs

    if (array_key_exists($key, $aggregations)) {

        // les valeurs sont dans un sous tableau, sous la clef 'key'

        $values = array_column($aggregations[$key], 'key');

        // on supprime les valeurs vides, inutiles 

        $valuesNotEmpty = array_filter($values, function ($value) {

            return $value !== null && $value !== "";

        });

        // et on renvoie un tableau avec seulement les choix retenus

        results[$key] array_combine($valuesNotEmpty, $valuesNotEmpty);

     }

  }

  return $results;

}


Dans tous les cas, les filtres proposés ne peuvent que donner des résultats. Tant qu'on en choisit qu'un à la fois, en rechargeant les filtres à chaque fois, on a toujours le plus grand ensemble de filtres pertinents
En bref, Great Success !

Une ouverture sur les aggregations autres que les bucket : gérer un filtre de prix

Ouais d'accord, ça semble pas mal ton truc. Mais si jamais je voulais filtrer par prix par exemple, ça marche plus du tout, j'vais quand même pas proposer toutes les valeurs de prix possible, ça n'a pas de sens commun !
Content de voir que tu es toujours là.
Sinon pour ton problème, c'est pour ça que j'ai parlé des aggregations de type metric tout à l'heure
Ici la question est un peu différente : en effet, les valeur max et min des prix doivent être basées sur l'ensemble du catalogue, et non pas seulement sur les valeurs filtrées : Sinon, une fois qu'on a commencé à filtrer par prix, il est impossible d'agrandir l'ensemble à nouveau.
Le principe est simple, il suffit de retenir les min/max lors de la première opération, et d'utiliser ça par exemple comme min et max pour un slider à double handle.
>Et parce que je t'aime bien, voici mon exemple de code avec ces filtres

 class SearchFilterData

 { 

     /** @var  int */

     protected $minPrice;

     /** @var  int */

     protected $maxPrice;

 }

Et une QueryBuilderProvider

class QueryBuilderProvider

{

    const MIN_SCORE = 0.05;

    const OFFSET = 0;

    const LIMIT = 10;

    

    public function provideQueryBuilder(SearchFilterData $data, $page = 1)

    {

        $queryBuilder = new QueryBuilder(

            self::OFFSET + ($page - 1) * self::LIMIT,

            self::LIMIT,

            self::MIN_SCORE

        );

        // on ajoute les aggregations sur les deux termes

        $queryBuilder

            ->addAggregation(new Aggregation('max_price', 'max', 'price'))

            ->addAggregation(new Aggregation('min_price', 'min', 'price'))

        if ($data->getMinPrice() !== null && $data->getMaxPrice() !== null) {

            $queryBuilder->addFilter(new RangeFilter('price', $data->getMinPrice(), RangeFilter::GREATER_THAN_OR_EQUAL_OPERATOR));

            $queryBuilder->addFilter(new RangeFilter('price', $data->getMaxPrice(), RangeFilter::LESS_THAN_OR_EQUAL_OPERATOR));

        }

    }

}

Et enfin le Form

class SearchFilterType extends AbstractType

{

    public function buildForm(FormBuilderInterface $builder, array $options)

    {    

        if (

            isset(

                $options['aggregations'][IndexableProduct::MIN_PRICE],

                $options['aggregations'][IndexableProduct::MAX_PRICE]

            )

        ) {

            $builder

                ->add('minPrice', SliderHandleType::class, [

                    'required' => false,

                    'attr' => [

                        'min' => $options['aggregations'][IndexableProduct::MIN_PRICE],

                        'max' => $options['aggregations'][IndexableProduct::MAX_PRICE],

                    ]

                ])

                ->add('maxPrice', SliderHandleType::class, [

                    'required' => false,

                    'attr' => [

                        'min' => $options['aggregations'][IndexableProduct::MIN_PRICE],

                        'max' => $options['aggregations'][IndexableProduct::MAX_PRICE],

                    ]

                ])

            ;

        }

    }    public function configureOptions(OptionsResolver $resolver)

    {

        $resolver->setRequired([

           'aggregations'

        ]);

        $resolver->setDefaults([

            'data_class' => SearchFilterData::class

        ]);

    }

}

Nous pouvons maintenant construire l'action:

public function listingAction(Request $request)

{

    // on créé la requete, sans filtres

    

    $filterData = new SearchFilterData();

    /** @var QueryBuilderProvider $this->queryBuilderProvider */

    $queryBuilder = $this->queryBuilderProvider->provideQueryBuilder($filterData);

       

    // puis on récupère les resultats, avec les aggregations, qui comportent toutes les valeurs existantes de prix 

    

    /** @var Novaway\ElasticsearchClient\QueryExecutor $this->queryExecutor */

    $results = $this->queryExecutor->execute(

        $queryBuilder->getQueryBody(),

        'my_index'

    ));

    

    // Il faut à ce moment initaliser les valeurs min et max price sur nos data

    // afin que les sliders soient bien positionnés aux extremes

    $filterData->setMinPrice($aggregations[IndexableProduct::MIN_PRICE]);

    $filterData->setMaxPrice($aggregations[IndexableProduct::MAX_PRICE]);

    

    $theoreticalExtremePrices =  [

            IndexableProduct::MIN_PRICE => $aggregations[IndexableProduct::MIN_PRICE],

            IndexableProduct::MAX_PRICE => $aggregations[IndexableProduct::MAX_PRICE],

        ];

            

    $form = $this->createForm(SearchFilterType::class, $filterData, [

        'aggregations' => $theoreticalExtremePrices

    ]);

    

    

    $form->handleRequest($request);

    

    if ($form->isSubmitted() && $form->isValid()) {

        // à ce moment, le $filterData contient les valeurs soumises.

        // Le form ayant été créé avec toutes les valeurs possibles, les valeurs postées peuvent être validées

        

        // on recommence l'opération, cette fois avec les filtres effectif

        /** @var QueryBuilderProvider $this->queryBuilderProvider */

        $queryBuilder = $this->queryBuilderProvider->provideQueryBuilder($filterData);

            

        /** @var Novaway\ElasticsearchClient\QueryExecutor $this->queryExecutor */

        $results = $this->queryExecutor->execute(

            $queryBuilder->getQueryBody(),

            'my_index'

        ));

        

        // les resultats, et les aggregations, tiennent compte cette fois des resultats filtrés

        // Néanmoins, les valeurs min et max doivent rester les memes qu'avant, afin de pouvoir revenir à ces valeurs  

        $aggregations = $this->extractAggregationsChoices($results->aggregations(), $theoreticalMinPrice, $theoreticalMaxPrice);

        $form = $this->createForm(SearchFilterType::class, $filterData, [

            'aggregations' => $theoreticalExtremePrices

        ]);

    }

    // render template

}