Extension Doctrine pour la fonction Match Against

Voici un tutorial Symfony2 pour ajouter la fonction Match Againts à Doctrine pour l’utiliser dans vos requêtes DQL.

Le serveur de base de données Mysql gère la fonction Match Againts uniquement avec le moteur MyISAM. Cela est vrais pour les versions inférieur à la 5.6. Dans la version 5.6 cette fonction sera aussi géré par le moteur innoDB. Ce tutorial est donc à adapter si vous bénéficier d’une version 5.6 ou plus.

Lorsque l’on créé une base de données avec Doctrine, il utilise par défaut le moteur innoDB car il gère les relations entre les tables. On va donc être obligé de s’adapter.

Dans ce tutorial, nous cherchons à faire une recherche Full Text sur certain champ d’une entité Fiche. Pour cela nous allons devoir coder un peu.
Dans un premier temps nous allons ajouter la fonction Match Againts à Doctrine (avec ses tests unitaires).
Nous devrons créer ensuite une classe qui servira uniquement à la recherche Full Text. C’est contraignant, mais si vous tenez à réduire le nombre de requête SQL émise par votre application, c’est un mal nécessaire.
Enfin nous ajouterons un écouteur pour synchroniser notre classe d’indexation avec notre fiche.

Commençons par créer l’extension de Doctrine. Si comme moi vous utilisez aussi une base de données SQLite pour vos tests fonctionnel, nous créerons aussi une extension pour cette base de données.

L’extension Match Againts pour Mysql

namespace TutoBundle\Extension\Doctrine\Query\Mysql;

use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\Parser;

/**
 * "MATCH_AGAINST" "(" {StateFieldPathExpression ","}* InParameter {Literal}? ")"
 */
class MatchAgainst extends FunctionNode {

    public $columns = array();
    public $needle;
    public $mode;

    public function parse(Parser $parser)
    {
        $parser->match(Lexer::T_IDENTIFIER);
        $parser->match(Lexer::T_OPEN_PARENTHESIS);

        do {
            $this->columns[] = $parser->StateFieldPathExpression();
            $parser->match(Lexer::T_COMMA);
        }
        while ($parser->getLexer()->isNextToken(Lexer::T_IDENTIFIER));

        $this->needle = $parser->InParameter();

        while ($parser->getLexer()->isNextToken(Lexer::T_STRING)) {
            $this->mode = $parser->Literal();
        }

        $parser->match(Lexer::T_CLOSE_PARENTHESIS);
    }

    public function getSql(SqlWalker $sqlWalker)
    {
        $haystack = null;

        $first = true;
        foreach ($this->columns as $column) {
            $first ? $first = false : $haystack .= ', ';
            $haystack .= $column->dispatch($sqlWalker);
        }

        $query = "MATCH(" . $haystack .
            ") AGAINST (" . $this->needle->dispatch($sqlWalker);

        if($this->mode && $this->mode->value != "") {
            $query .= " " . trim($this->mode->dispatch($sqlWalker), "'") . ")";
        } else {
            $query .= ")";
        }

        return $query;
    }
}

Voici maintenant pour SQLite. Vous remarquerez que l’on fait un simple LIKE. SQLite ne gère pas la fonction Match Against…

namespaceTutoBundle\Extension\Doctrine\Query\Sqlite;

use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\Parser;

class MatchAgainst extends FunctionNode {

    public $columns = array();
    public $needle;
    public $mode;

    public function parse(Parser $parser)
    {

        $parser->match(Lexer::T_IDENTIFIER);
        $parser->match(Lexer::T_OPEN_PARENTHESIS);

        do {
            $this->columns[] = $parser->StateFieldPathExpression();
            $parser->match(Lexer::T_COMMA);
        }
        while ($parser->getLexer()->isNextToken(Lexer::T_IDENTIFIER));

        $this->needle = $parser->InParameter();

        while ($parser->getLexer()->isNextToken(Lexer::T_STRING)) {
            $this->mode = $parser->Literal();
        }

        $parser->match(Lexer::T_CLOSE_PARENTHESIS);
    }

    public function getSql(SqlWalker $sqlWalker)
    {
        $likes = array();

        foreach ($this->columns as $column) {
            $likes[] = $column->dispatch($sqlWalker) . ' LIKE ' . $this->needle->dispatch($sqlWalker);
        }

        $query = implode(' OR ', $likes);

        return $query;
    }
}

Nous allons maintenant informer Doctrine de l’existence de ces deux extensions.
Pour Mysql rendez-vous dans votre fichier app/config/config.yml. Il suffît d’ajouter le paramètre string_functions.

# Doctrine Configuration
doctrine:
    orm:
        dql:
            string_functions:
                MATCH_AGAINST: TutoBundle\Extension\Doctrine\Query\Mysql\MatchAgainst

Pour définir celle réservé au test éditez le fichier app/config/config_test.yml

doctrine:
    orm:
        dql:
            string_functions:
                MATCH_AGAINST: TutoBundle\Extension\Doctrine\Query\Sqlite\MatchAgainst

Maintenant que notre fonction Match Against est référencé au près de Doctrine, voyons l’entité Fiche sur laquelle nous volons faire notre recherche Full Text.

namespaceTutoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 * @ORM\Table(name="fiches")
 */
class Fiche
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(name="id", type="integer", nullable=false, unique=true)
     */
    protected $id;

    /**
     * @ORM\Column(name="titre", type="string", length=256, nullable=true)
     */
    protected $titre;

    /**
     * @ORM\Column(name="description", type="text", nullable=true)
     */
    protected $description;

    /**
     * Pas de liaison car cela génère trop de requêtes inutiles
     */
    protected $searchIndexFiches;
//...
}

C’est une bête entité qui ne comporte rien d’extravagant. Mais comme je vous l’ai précédemment dit, on ne peut pas utiliser cette entité pour effectuer des requêtes de recherches. Pour ce tutorial elle est simple, mais en réalité elle sera bien plus compliquée.

Notre entité SearchIndexFiche ne va comporter que les champs dans lesquels nous voulons faire notre recherche.

namespace TutoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="search_index_fiches", options={"engine"="MyISAM"})
 */
class SearchIndexFiches
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(name="id", type="integer", nullable=false, unique=true)
     */
    protected $id;

    /**
     * @ORM\Column(name="titre", type="string", length=256, nullable=true)
     * @var string $titre
     */
    protected $titre;

    /**
     * @ORM\Column(name="description", type="text", nullable=true)
     *
     * @var text $description
     */
    protected $description;

    /**
     * @ORM\Column(name="fiche_id", type="integer", nullable=true)
     */
    protected $fiche;
}

Pour lié ces deux entités, nous allons créer un écouteur d’évènement Doctrine.
Lorsqu’une fiche sera créé, mise à jours ou supprimé, nous devons répercuter ces évènements sur l’entité SearchIndexFiches.

<?php

namespace TutoBundle\Event;

use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\LifecycleEventArgs;
use TutoBundle\Entity\Fiches;
use TutoBundle\Entity\SearchIndexFiches;

class SearchIndexerSubscriber implements EventSubscriber
{

    public function getSubscribedEvents()
    {
        return array('postPersist', 'postUpdate', 'postRemove');
    }

    public function postUpdate(LifecycleEventArgs $args)
    {
        $this->index($args);
    }

    public function postPersist(LifecycleEventArgs $args)
    {
        $this->index($args);
    }

    public function postRemove(LifecycleEventArgs $args)
    {
        $entityManager = $args->getEntityManager();

        /* @var $res SearchIndexFiches */
        $res = $entityManager
                ->getRepository("TutoBundle:SearchIndexFiches")
                ->findOneByFiche($args->getEntity()->getId());

        if($res !== null) {
            $entityManager->remove($res);
            $entityManager->flush();
        }
    }

    public function index(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
        $entityManager = $args->getEntityManager();

        if ($entity instanceof Fiches) {

            /* @var $res SearchIndexFiches */
            $res = $entityManager->getRepository("TutoBundle:SearchIndexFiches")->findOneByFiche($entity->getId());

            if($res === null) {
                $res = new SearchIndexFiches;
            }

            // Ici on duplique ce qui est nécessaire
            $res->setTitre($entity->getTitre())
                ->setDescription($entity->getDescription())
                ->setFiche($entity->getId());

            $entityManager->persist($res);
            $entityManager->flush();
        }
    }
}

Une fois cette écouteur fait, nous devons le relier à Doctrine. Rien de plus simple. Éditez votre fichier Resources/config/services.xml et ajoutez ce qui suit :

<service id="my.doctrine.subscriber" class="TutoBundle\Event\SearchIndexerSubscriber">
        <tag name="doctrine.event_subscriber" connection="default" />
</service>

Voilà ! Ça devrait fonctionner.

Un petit exemple d’utilisation ?

$qb = $this->createQueryBuilder("fiche");
$qb->select("fiche, MATCH_AGAINST(fiche.titre, fiche.description, :textFilter 'IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION') as score")
                    ->where("MATCH_AGAINST(fiche.titre, fiche.description, :textFilter 'IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION') > 0.8")
                    ->setParameter("textFilter", ”Un texte à rechercher”)
                    ->orderBy("score")->getQuery()->getResult();

Mais ce n’est pas finit !
Je vous propose de créer le test unitaire pour la méthode Match Against. Il vont simplement vérifier que les requêtes sont correctement construite.

En premier lieux il nous faut une entité basique, soit :

namespace TutoBundle\Tests\Extension\Doctrine\Query\Mysql\Entity;

/**
 * @Entity
 */
class Dumb
{
    /** @Id @Column(type="string") @GeneratedValue */
    public $id;

    /**
     * @Column(type="String")
     */
    public $titre;

    /**
     * @Column(type="String")
     */
    public $desc;
}

Et voici notre test :

namespace TutoBundle\Tests\Extension\Doctrine\Query\Mysql;

use Doctrine\ORM\Configuration;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\ORM\EntityManager;

class MatchAgainstTest extends \PHPUnit_Framework_TestCase
{

    public $entityManager = null;

    public function setUp()
    {
        $config = new Configuration();
        $config->setMetadataCacheImpl(new ArrayCache());
        $config->setQueryCacheImpl(new ArrayCache());



        $config->setProxyDir(sys_get_temp_dir() . '/Proxies');
        $config->setProxyNamespace('DoctrineExtensions\Tests\Proxies');
        $config->setAutoGenerateProxyClasses(true);
        $config->setMetadataDriverImpl($config->newDefaultAnnotationDriver(__DIR__ . '/Entity'));


        $config->setCustomStringFunctions(array(
            'MATCH_AGAINST' => 'TutoBundle\Extension\Doctrine\Query\Mysql\MatchAgainst',
        ));

        $this->entityManager = EntityManager::create(
                        array('driver' => 'pdo_sqlite', 'memory' => true),
                        $config
        );
    }

    public function testWithMode()
    {
        $q = $this->entityManager->createQuery("SELECT MATCH_AGAINST(s.titre, s.desc, :textFilter 'IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION') as score"
                . " from TutoBundle\Tests\Extension\Doctrine\Query\Mysql\Entity\Dumb s");

        $this->assertEquals(
                "SELECT MATCH(d0_.titre, d0_.desc) AGAINST (? IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION) AS sclr0 FROM Dumb d0_",
                $q->getSQL());
    }

    public function testWithMode2()
    {
        $q = $this->entityManager->createQuery("SELECT MATCH_AGAINST(s.titre, s.desc, :textFilter 'IN NATURAL LANGUAGE') as score"
                . " from TutoBundle\Tests\Extension\Doctrine\Query\Mysql\Entity\Dumb s");

        $this->assertEquals(
                "SELECT MATCH(d0_.titre, d0_.desc) AGAINST (? IN NATURAL LANGUAGE) AS sclr0 FROM Dumb d0_",
                $q->getSQL());
    }

    public function testWithoutMode()
    {
        $q = $this->entityManager->createQuery("SELECT MATCH_AGAINST(s.titre, s.desc, :textFilter') as score"
                . " from TutoBundle\Tests\Extension\Doctrine\Query\Mysql\Entity\Dumb s");

        $this->assertEquals(
                "SELECT MATCH(d0_.titre, d0_.desc) AGAINST (?) AS sclr0 FROM Dumb d0_",
                $q->getSQL());
    }
}

Pour construire tout cela je me suis basé sur plusieurs ressources que voici.

Un thread sur google groupe au sujet de Doctrine 2 – MATCH AGAINST
http://groups.google.com/group/doctrine-user/browse_thread/thread/69d1f293e8000a27

Un exemple d’extension Doctrine réalisé par Jérémy Hubert : https://gist.github.com/1234419

Je me suis basé sur le code de Benjamin Eberlei pour construire le teste unitaire : https://github.com/beberlei/DoctrineExtensions/blob/master/tests/Query/Mysql/StringTest.php

Tagués avec : , ,
Publié dans Doctrine, PHP, Symfony2

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

*