Elasticsearch et Symfony : Hydrater son objet grâce aux Transformers

Share Button

Read the English version

Quand et pourquoi hydrater son objet avec les Transformers?

Quand on utilise Elasticsearch et les Finders du FOSElasticaBundle, les réponses reçues d’Elasticsearch sont automatiquement transformées en objets Doctrine. 

Dans certains cas, vous aurez besoin d’afficher une information issue d’une jointure : la photo, la traduction d’une catégorie, …
Malheureusement, l’hydratation est faite uniquement sur les objets en question : FOSElasticaBundle transforme les ids correspondant à la recherche en objets via un select * in (:ids).
Conséquence : pour chaque entrée dans votre liste de résultats, Doctrine ira chercher la Category associée. Pour 100 enregistrements dans 100 catégories différentes, 100 requêtes simples seront ajoutées. On se rend compte de ce qui peut arriver avec un modèle de données compliqué et plusieurs jointures.

Heureusement, pour éviter cela, vous pouvez surcharger le Transformer.

Pour les besoins de l’exemple (Hydrater la catégorie en même temps que l’article), nous allons créer un objet Category lié par une oneToMany à l’Article.

<?php

namespace Obtao\BlogBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use FOS\ElasticaBundle\Configuration\Search;

class Category
{
     /**
     * @ORM\Column(type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    protected $id;
   
    /**
     * @ORM\Column(type="string")
     */
    protected $name;
}

Et nous allons également déclarer la relation coté Article.

<?php

namespace Obtao\BlogBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use FOS\ElasticaBundle\Configuration\Search;

class Article
{
     // ... (voir l'article http://obtao.com/blog/2014/01/indexation-et-recherche-simple-sur-elasticsearch-et-symfony2)

     /**
     *
     * @ORM\ManyToOne(targetEntity="Obtao\BlogBundle\Entity\Category", inversedBy="articles" ,cascade={"persist"})
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id", nullable=false)
     */
    protected $category;
}

Créer un service

La première étape est de créer le service qui transformera les ids en objets Doctrine.

Le code pour ce Transformer est assez simple, on construit un QueryBuilder avec une jointure sur Category et un where id in (:ids).

<?php

namespace Obtao\BlogBundle\Transformer;

use FOS\ElasticaBundle\Doctrine\AbstractElasticaToModelTransformer;
use Doctrine\ORM\Query;

class ElasticaToArticleTransformer extends AbstractElasticaToModelTransformer
{
    /**
     * Fetch objects for theses identifier values
     *
     * @param array $identifierValues ids values
     * @param Boolean $hydrate whether or not to hydrate the objects, false returns arrays
     * @return array of objects or arrays
     */
    protected function findByIdentifiers(array $identifierValues, $hydrate)
    {
        if (empty($identifierValues)) {
            return array();
        }
        $hydrationMode = $hydrate ? Query::HYDRATE_OBJECT : Query::HYDRATE_ARRAY;
        $qb = $this->registry
            ->getManagerForClass('ObtaoBlogBundle:Article')
            ->getRepository('ObtaoBlogBundle:Article')
            ->createQueryBuilder('a')
            ->select('a,c')
            ->join('a.category','c');
        /* @var $qb \Doctrine\ORM\QueryBuilder */
        $qb->where($qb->expr()->in('a.'.$this->options['identifier'], ':values'))
            ->setParameter('values', $identifierValues);

        return $qb->getQuery()->setHydrationMode($hydrationMode)->execute();
    }
}

La déclaration de ce service reprend les arguments du Transformer par défaut d’Elastica
- Doctrine
- Model concerné
- Options
- Le property accessor

<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <service id="obtao.blog.transformers.elastica.article" class="Obtao\BlogBundle\Transformer\ArticleElasticaToModelTransformer">
            <argument type="service" id="doctrine" />
            <argument /> 
            <argument type="collection" /> 
            <call method="setPropertyAccessor">
                <argument type="service" id="fos_elastica.property_accessor" />
            </call>
            <tag name="fos_elastica.elastica_to_model_transformer" type="article" index="obtao_blog" />
        </service>
    </services>
</container>

Votre Transformer est maintenant prêt à être utilisé.

Modifier le fichier de config fos_elastica.yml 

Pour que FOSElasticaBundle prenne en compte ce Transformer, il faut modifier sa configuration :

# app/config/fos_elastica.yml

article : 
    # ... (voir l'article http://obtao.com/blog/2014/01/indexation-et-recherche-simple-sur-elasticsearch-et-symfony2)
    persistence:
    driver: orm
    model: Obtao\BlogBundle\Entity\Article
    elastica_to_model_transformer:
        service: obtao.blog.transformers.elastica.article
    finder: ~
    provider: ~
    listener: ~

Désormais, lors de l’utilisation du Finder sur l’objet Article , votre Transformer sera appelé et les catégories seront récupérées avec leurs jointures déjà hydratées.
De nombreuses requêtes inutiles peuvent ainsi être évitées.

Pour en savoir plus sur Elasticsearch dans un projet Symfony (mais pas que), lisez nos autres articles sur le sujet.

Le code utilisé dans cet article peut être téléchargé depuis notre projet bac à sable sur Github.

Share Button

16 thoughts on “Elasticsearch et Symfony : Hydrater son objet grâce aux Transformers

  1. Bonjour,

    J’ai bien suivi votre article, cependant j’ai une notice :

    Notice: Undefined index: xxx in /Users/xxxx/Sites/xxx/vendor/friendsofsymfony/elastica-bundle/FOS/ElasticaBundle/Transformer/ElasticaToModelTransformerCollection.php line 55

    Et je ne trouve pas la raison. Je suis a la version 2.1.*

    Une idée ?

    • Bonjour,

      Je vous envoie un mail afin de mieux comprendre l’erreur. En parallèle, nous vérifions le contenu du post.
      Je reviendrai apporter une solution ici une fois le mystère résolu (pour les autres lecteurs)

      • Bonjour,
        pour commencer merci pour ce post. Ce n’est pas évident de se documenter sur ce bundle.
        J’ai le même souci que samuel quéniart. Donc tout se passe bien, sauf quand je veux utiliser mon finder, dans une recherche.

        J’ai un index du style :
        website:
        client: default
        finder: ~
        settings:
        analysis:
        analyzer:
        str_search_analyzer:
        tokenizer: “keyword”
        filter: “lowercase”
        str_index_analyzer:
        tokenizer: keyword
        filter: ["lowercase", "substring"]

        filter:
        substring:
        type: “nGram”
        min_gram: 1
        max_gram: 20
        types:
        user:
        mappings:
        id: ~
        persistence:
        driver: orm
        model: P4M\UserBundle\Entity\User
        provider: ~
        finder: ~
        repository: P4M\UserBundle\SearchRepository\UserRepository
        post:
        mappings:
        id: ~
        title: { boost: 5 }
        #…..
        persistence:
        driver: orm
        model: P4M\CoreBundle\Entity\Post
        provider: ~
        finder: ~
        listener:
        is_indexable_callback: “isSearchable”
        repository: P4M\CoreBundle\SearchRepository\PostRepository
        elastica_to_model_transformer:
        service: p4m.transformers.elastica.post

        La notice apparait dès que j’essaye d’utiliser le finder de cet index.
        $finder = $this->container->get(‘fos_elastica.finder.website’);
        Le service est mal hydraté et il ne reçoit pas le transformer custom que nous avons créé (p4m.transformers.elastica.post).

        Avez vous une piste à me donner pour avancer ?

        Merci :)

    • J’ai une solution. En fait il manque un tag dans la définission du service.

      voici le mien :
      [code]
      p4m.transformers.elastica.post:
      class : 'P4M\MyElasticaBundle\Transformer\ElasticaToPostTransformer'
      arguments:
      - @doctrine
      - 'P4M\CoreBundle\Entity\Post'
      calls:
      - [ setPropertyAccessor ,[@fos_elastica.property_accessor]]
      - [ setIgnoreMissing ,[true]]
      tags:
      - {name: 'fos_elastica.elastica_to_model_transformer', type: post, index: indexName}

      [/code]

      (Je me suis aussi créé une méthode pour pouvoir mettre l’option ignore missing à true)

  2. Bonjour,

    J’ai l’impression que mes entités ne s’hydratent pas correctement. Je suis obliger d’insérer une méthode to_string pour que mon populate fonctionne et bien entendu, ce n’est pas le but.

    Je ne sais malheureusement pas ou se trouve mon erreur ou plutôt je soupçonne la déclaration de service. la transformation xml vers yml n’est peut être pas bonne:

    demo.acme.transformers.elastica.hotel:
            class: Demo\AcmeBundle\Transformer\ElasticaToHotelTransformer
            arguments: ["@doctrine"]
            calls:
                - [ setPropertyAccessor, [ @fos_elastica.property_accessor ] ]
    

    D’avance je vous remercie pour l’aide que vous pourrez m’apporter.

    • Bonjour,
      Je ne comprends pas bien votre problème, pouvez-vous nous en dire plus?

      S’agit-il d’un problème au moment de l’indexation (commande populate / mapping / transformation d’entités en document ElasticSearch XxxToElasticaTransformer) ou un problème de recherche (transformation de résultats ES en entités doctrine / ElasticaToXxxTransformer) ?

      François

      • Je pense que mon service n’est pas prit en compte. Je lance le populate mais j’obtiens l’erreur :”Object or class ….. could not be converted to string….”

        Et donc je pense avoir un problème au niveau de mon service. On dirait qu’il n’est pas utilisé!

        config.yml :

        driver: orm
        model:  Acme\DemoBundle\Entity\Hotel
        elastica_to_model_transformer:
            service: acme.demo.transformers.elastica.hotel
         finder: ~
        provider: ~
        listener: ~
        

        services.yml:

        acme.demo.transformers.elastica.hotel:
            class: Demo\AcmeBundle\Transformer\ElasticaToHotelTransformer
            arguments: [@doctrine, "Demo\AcmeBundle\Entity\Hotel"]
        
        • Le “ElasticaToHotelTransformer” sera appelé lors d’un retour de recherche, il n’est pas utilisé lors du populate et de l’indexation.

          Je pense que l’un des éléments déclarés dans le mapping est un object, et que FOSElastica ne sait pas comment le stocker. (D’ou le besoin pour vous de créer la méthode toString)
          Vous pouvez au choix :

          - Créer un HotelToElasticaTransformer (l’inverse du ElasticaToHotelTransformer). Cette solution est pratique si vous avez besoin d’applatir plusieurs champs de votre objet :
          Dans le config.yml

          driver: orm
          model:  Acme\DemoBundle\Entity\Hotel
          model_to_elastica_transformer:
              service:    acme.demo.transformers.model.hotel          
          

          - Stocker simplement un Id ou une chaine de caractère qui représente l’objet. Si vous utilisez ce champ pour effectuer une recherche texte, préférez la chaine, sinon préférez l’id (qui vous permettra de retrouver l’objet)
          Pour cela, vous pouvez créer une méthode sur votre objet Hotel qui retourne l’id (ou la chaine) qui correspond. Vous pourrez l’ajouter au mapping
          Par exemple :
          Dans Hotel.php

           public function getCityName(){
               if(is_object($this->getCity())){
                   return $this->getCity()->getName();
               }
               return null;
          }
          

          Dans le config.yml

              mappings:
                    cityName : ~
          

          - Utiliser le jmsserializer pour gérer la sérialisation de votre objet (cf doc du FOSElasticaBundle)

          J’espère vous éclairer un peu sur votre problème,
          François

  3. Bonjour,
    Enfin je trouve une documentation sur ce problème, il m’a fallu chercher loin cependant ! Merci mille fois pour cet article !

    Par contre, j’ai une question, comment feriez-vous pour trier les objets hydratés en fonction du score qu’ils ont obtenu dans elasticsearch pour la requête demandée ?

    Merci d’avance,
    Cordialement,
    Yannis.

    • Bonjour et merci,
      Logiquement, le sort est à implémenter directement dans la requête. Un truc du genre $query->addSort(‘_score’); (je ne suis pas sur de la syntaxe exacte, peut qu’il faut mettre array(‘_score’))

      • Okay, il faudra que je regarde (j’ai posé la question sans avoir eu le temps de tester :/), j’avais un doute quant au fait qu’on faisait un “where in” sur les IDs des entités dans la requête pour les récupérer.

  4. Bonjour,
    Je n’ai pas tout saisi dans cette article. Si j’ai une entité A lié a une entité B et que je souhaite dans mon formulaire de recherche avoir 2 champs de filtre, 1 propriété de l’entité A et une autre propriété de l’entité B ( autre que l’id). Comment dois je procéder? indexer les deux entité ( avec les attributs que l’on souhaite), construire le transformer qui indique la jointure entre les tables, construire un model par entité,et un seul formtype classique lorsqu’on utilise une entité lié avec add new entité B?

    merci.

    • Bonjour,

      Je pense que la meilleure solution est effectivement de faire une troisième entité qui va correspondre exactement à votre formulaire de filtre. Pour la partie Elasticsearch, vous indexerez les données qu’il vous faut. Par exemple, entiteA.name et entiteA.entiteB.name (je suppose que vos 2 entitées sont liées d’une quelconque manière). La surcharge du Transformer décrite dans cet article est un autre sujet : quand vous faites une recherche dans Elasticsearch, il vous retourne un certain nombre d’objets (via Doctrine). Le but de cet article est en gros de tout récupérer en 1 seule requête et non plusieurs.

      Pour votre problème, lisez les 2 articles suivants :
      - pour la création du model et formulaire de filtre : http://obtao.com/blog/2014/02/indexation-et-recherche-simple-sur-elasticsearch-et-symfony/
      - pour l’indexation et la recherche dans 2 entités liées : http://obtao.com/blog/2014/04/elasticsearch-recherche-avancee-nested/

      (le second article est la suite du premier. Nous avons ajouté la notion de Catégorie pour illustrer la manière de gérer 2 entitées liées). Bon courage!

      • J’ai deja lu ces articles j’ai réussi a implémenter la méthode du premier article sur mon entité. je l’ai fait juste pour trier mes entité entre deux date d’éxécution( il s’agit de création d’activité). Sauf que mon entité est beaucoup plus complexe que ca. mon entité est lié en many to one avec une entité listeactivité afin de choisir une activité. cette deuxième entité est en manytomany avec une entité catégorie( certaines activitées appartiennent a plusieurs catégories) lors d’une création d’activité l’utilisateur une catégorie puis une activité dans cette catégorie.

        mon entité de création est aussi en many to one avec des entité departement et villefrance. l’utilisateur choisit un département puis une ville du département.

        Ce que j’aimerai faire dans ma recherche c’est créé un formulaire puisse filtrer par catégorie, activité dans une catégorie si il le souhaite. département ou ville avec u périmètre.
        pour la partie model et formtype je devrai m’en sortir par contre pour le finder je nage un peu.

        si je comprends bien le deuxième article que vous me proposez montre comment créer les jointures( qu’elasticsearch ne reconnait pas) entre des entités liés afin de pouvoir les utiliser dans le repository search?

        y a til un artcile traiant du provider? celui par défaut index t il automatiquement les nouvelles entité, mises a jour, suprression?

  5. Pingback: ElasticSearch 嵌套映射和过滤器及查询 | 解决方案 | IT技术社区

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