Doctrine

DOCTRINE

L’ORM Doctrine offre beaucoup plus de flexibilité qu’il n’y paraît.
Cet article vous présente plusieurs trucs et astuces très utiles de Doctrine: évènements et listeners, filtres, tracking policy, mais aussi des astuces sur des architectures possibles pour son code.

Cet article est issu du talk de Romain Drigon dont le but était de détailler des fonctionnalités plus avancées de Doctrine, souvent inconnues, et de voir comment elles pouvaient nous aider face à des problèmes de performance ou d’organisation du code, par exemple quand on ne sait pas où mettre certaines logiques-métier. Ce talk a également permis de se pencher plus en avant sur les pratiques à mettre en oeuvre et celles à éviter.

Commençons par le commencement :)

SELECT

Est-ce que vous savez ce qu’il se passe sous la capot lorsque vous sélectionnez une entité ?

  • Doctrine ORM regarde dans le mapping (ClassMetadata) quel est l’identifiant de l’entité
  • Dans le cas d’une requête par ID, Doctrine regarde dans l’identityMap si l’entité n’a pas déjà été chargée
  • si non, Doctrine va générer une requête SQL
  • Doctrine DBAL va l’exécuter
  • Doctrine ORM va construire un objet à partir du résultat (hydratation),

    l’entité sera ajoutée à l’identityMap, puis retournée

DQL: une abstraction de SQL

Doctrine utilise DQL (Doctrine Query Language) comme langage de requête. C’est un langage de requête conçu à l’image de Hibernate Query Language (HQL), un ORM pour Java.

DQL fournit de puissantes capacités d’interrogation sur des modèles objet. Il permet de réaliser ainsi presque toutes les requêtes que supporte le SQL natif en se basant exclusivement sur des modèles objet.

// class UserRepository
$query = $this->createQueryBuilder('u')
    ->where('u.name = :name')
    ->getQuery();
    
// SELECT u FROM UserBundle\Entity\User u WHERE u.name = :name
$query->getDQL();

// SELECT u0_.id AS id_0, u0_.name AS name_0 ... FROM user u0_ WHERE u0_.name = ?
$query->getSQL();

DQL: fonctions

Il est possible d’utiliser des fonctions SQL dans DQL afin d’effectuer des requêtes plus élaborées:

$query = $this->createQueryBuilder('u')
    ->where('u.name = :name')
    ->andWhere("u.expiresAt < DATE_ADD(CURRENT_DATE(), 1, 'day')")
    ->getQuery();

On peut aussi tout écrire en DQL:

$dql = "SELECT CONCAT(u.firstname, ' ', u.lastname)
    FROM UserBundle\Entity\User u
    WHERE LOWER(u.company) = :company
    AND u.expiresAt BETWEEN DATE_SUB(CURRENT_DATE(), 1, 'day')
    AND DATE_ADD(CURRENT_DATE(), 1, 'day')";
        
$query = $this->getEntityManager()->createQuery($dql);

Changer l’hydratation

Principalement pour des raisons de performance, l’hydratation en objet, surtout avec des objets associés, est très lourde. Par conséquent, il peut être intéressant dans certains cas de changer l’hydratation. Plusieurs modes sont disponibles:

  • HYDRATE_OBJECT - Blog, object construit par Reflection
  • HYDRATE_ARRAY - tableau associatif avec id, name…
  • HYDRATE_SCALAR - tableaux avec b_id, b_name… (non-dédupliqué !)
  • HYDRATE_SINGLE_SCALAR - retourne une valeur scalaire (count(id))
  • HYDRATE_SIMPLEOBJECT - Blog mais sans objects joints (1-to-1) (!)

Si les getters sont typés, l’utilisation de HYDRATE_SIMPLEOBJECT peut avoir des effets indésirables.

Partial object

Doctrine, par défaut, n’autorise pas les objets partiels. Cela signifie que, toute requête qui sélectionne uniquement les données d’objet partiel et veut récupérer le résultat sous forme d’objets lèvera une exception vous indiquant que des objets partiels sont dangereux. Si vous voulez forcer une requête pour renvoyer des objets partiels, vous pouvez utiliser le mot clé partial comme suit:

// Dans BlogRepository
$query = $this->createQueryBuilder('b')
    ->select('PARTIAL b.{id, name}')
    ->where('b.name = :name')
    ->setParameter('name', 'Mikhail')
    ->getQuery();
    
$blogs = query->getResult();
dump($blogs[0] instanceof Blog); // true
dump($blogs[0]->getId()); // 1
dump($blogs[0]->getName()); // Mikhail
dump($blogs[0]->getDescription()); // null

Tous les autres champs seront null, les associations et collections auront des proxy non inutilisés / vides.

Ou encore les Partials References

Lorsqu’on veut une entité que pour son ID, il est possible d’utiliser un autre type d’objet partiel, une référence:

$blog1 = $entityManager->getPartialReference(Blog::class, 1);

dump($blog1 instanceof Blog); // true
dump($blog1->getId()); // 1

$articles = $entityManager
    ->getRepository(Article::class)
    ->findBy(['blog' => $blog1]);

Relations / associations

La gestion des relations bidirectionnelles par Doctrine n’est pas une tâche anodine. Dans une relation, il y a l’owning side (requis) et l’inverse side.

Dans toutes les relations, Doctrine ne vérifie les changements que d’un seul côté pour mettre à jour la relation. Ce côté responsable de la relation est appelé l’owning side. Réciproquement, l’autre côté de la relation est appelé l’inverse side.

Relations et proxy

Dans la mesure du possible, Doctrine propose du lazy loading, c’est-à-dire de mettre soit un proxy soit une PersistentCollection à la place des entités jointes.

Type Côté Peut être proxy ?
One-to-One Owning Oui (ou null)
One-to-One Inverse Jamais !
Many-to-One Owning Oui (ou null)
Many-to-One (One-to-Many) Inverse Oui, Collection
Many-to-Many Owning Oui, Collection
Many-to-Many Inverse Oui, Collection

Avec le lazy loading les performances de l’application vont se dégrader de manière exponentielle au fur et à mesure que la base de données va grossir. On parle alors du problème N+1 provoqué par l’utilisation de l’anti-pattern qui consiste à exécuter une requête pour obtenir la relation parente, puis à récupérer les enfants un à un.

Solutions:
  • On peut demander à Doctrine de récupérer en même temps les parents et les enfants en faisant des jointures. On garde le problème du coût de l’hydratation (coût en O(n*m) avec n parents et m enfants).
  • Avoir recours au multi-step hydratation (plus de détails sur le blog de Marco Pivetta)
$blogs = $entityManager->createQuery('
    SELECT blog, article
    FROM Blog blog
    LEFT JOIN blog.articles article
')
->getResult();

$entityManager->createQuery('
    SELECT PARTIAL blog.{id}, author
    FROM Blog blog
    LEFT JOIN blog.authors author
')
->getResult(); // Résultat inutile

$blogs[0]->getArticles()->first->getTitle(); // Ne déclenche pas de requête

Les proxies supportent mal la sérialisation. A la place, il faut soit charger le proxy en invoquant la méthode $proxy->__load() juste avant sa sérialisation, soit utiliser l’identité en sérialisant juste son id.

Requêtage, astuce

1. Les Criteria

Les criterias permettent d’effectuer certaines opérations directement sur la collection d’objets chargée en mémoire.

class Blog
{
    public function getDraftArticles(): Collection
    {
        $criteria = Criteria::create()
            ->where(Criteria::expr()->eq('status', 'draft'))
            ->orderBy(['position' => Criteria::ASC]);
            
            return $this->articles->matching($criteria);
    }
}

Si la collection n’est pas chargée, une requête SQL avec un WHERE sera exécutée, sinon le filtrage aura lieu sur les éléments en mémoire.

2. Appliquer un filtre à toutes les requêtes

Doctrine permet d’injecter de manière automatique du code SQL dans les clauses conditionnelles des requêtes, y compris lors du chargement des entités issues des relations.

class NoDraftFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $entityMetadata, $alias)
    {
        if (Article::class !== $entityMetadata->reflClass->getName()) {
            return '';
        }
            
        return $alias.".status != 'draft'"; // DQL injecté dans le WHERE
    }
}

$publishedArticles = $entityManager->getRepository(Article::class)->findAll();

Il ne reste plus qu’à ajouter un peu de config et le tour est joué:

# config/packages/doctrine.yaml
orm:
    entity_managers:
        default:
            filters:
                draft_filter:
                    class: App\Filter\NoDraftFilter
                    enabled: true

Le filtre peut être facilement désactivé via FiltersCollection stockée dans EntityManager:

$entityManager->getFilters()->disable('draft_filter');
$allArticles = $entityManager->getRepository(Article::class)->findAll();

3. Organiser ses repositories

Pensez à organiser vos repositories de manière à faciliter la réutilisation de code en créant une méthode par clause conditionnelle:

class ArticleRepository
{
    public function findOnlineArticlesIWroteOnBlog(User $user, Blog $blog)
    {
        $queryBuilder = $this->createQueryBuilder('a');
        $this->withIsOnline($queryBuilder, 'a');
        $this->withIWrote($queryBuilder, 'a', $user);
        $this->withFromBlog($queryBuilder, 'a', $blog);
        
        return $queryBuilder->getQuery()->getResult();
    }
    
    private function withIsOnline(QueryBuilder $queryBuilder, string $alias)
    {
        $queryBuilder
            ->andWhere($alias.".status != 'draft'")
            ->andWhere($alias.'.publishOn >= CURRENT_TIMESTAMP()')
        ;
    }
    
    // withIWrote(), withFromBlog(), etc.
}

Ou bien à déporter la logique de construction de QueryBuilder dans une classe à part afin de ne pas trop surcharger les repositories:

class ArticleQueryBuilderBuilder
{
    private $queryBuilder;
    private $tableAlias;
    
    public function __construct(QueryBuilder $queryBuilder, string $tableAlias)
    {
        $this->queryBuilder = $queryBuilder;
        $this->tableAlias = $tableAlias;
    }
    
    public function withIsOnline()
    {
        ...
    }
    
    // withIWrote(), withFromBlog(), etc.
    
    $articles = $entityManager->getRepository(Article::class)
        ->getArticleQueryBuilderBuilder() // A ajouter dans ArticleRepository
        ->withIsOnline()
        ->withIWrote($user)
        ->withFromBlog($blog)
        ->getQueryBuilder()->getQuery()->getResult();
}

INSERT

Est-ce que vous savez ce qu’il se passe sous la capot lorsque vous insérez une nouvelle entité ?

  • Doctrine détecte qu’il ne connaît pas l’entité, il va l’ajouter dans UnitOfWork::entityInsertions: $entityManager->persist($newEntity)
  • Maintenant, Doctrine va synchroniser l’UnitOfWork avec la base de données: il regarde s’il y a de nouvelles entités, ouvre une transaction, génère et exécute un INSERT SQL, puis commit et l’UnitOfWork se “nettoie”: $entityManager->flush()

Pour nous permettre d’agir sur les entités durant leur cycle de vie, l’entity manager de Doctrine génère des événements pour chaque opération qu’il effectue sur une entité. Ainsi, nous avons à notre disposition un ensemble d’événements et nous pouvons citer entre autres:

Action Quand ? Evènements
Nouvelle entité EM::flush() prePersist et postPersist
Mise à jour EM::flush() preUpdate et PostUpdate
Suppression EM::flush() preRemove et PostRemove
Toujours EM::flush() preFlush, onFlush et postFlush
Lecture de la BDD find() postLoad
Première opération find(), EM::persist() loadClassMetadata
Nettoyage EM::clear() onClear

1. Lifecycle callbacks

Un callback est une méthode de votre entité, et on va dire à Doctrine de l’exécuter à certains moments.

Tout d’abord, on doit dire à Doctrine que notre entité contient des callbacks de cycle de vie. Cela se définit grâce à l’annotation HasLifecycleCallbacks dans le namespace habituel des annotations Doctrine. Cette annotation permet à Doctrine de vérifier les callbacks éventuels contenus dans l’entité. Elle s’applique à la classe de l’entité, et non à un attribut particulier.

Ensuite, il faut définir des méthodes et surtout, les évènements sur lesquels elles seront exécutées.

use Doctrine\Common\Persistence\Event\PreUpdateEventArgs;

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks
 */
class Blog
{
    /** @ORM\PreUpdate */
    public function onPreUpdate(PreUpdateEventArgs $event)
    {
        $this->updateAt = new \DateTimeImmutable();
    }
}

Les callbacks définis directement dans les entités sont pratiques car très simple à mettre en place. Quelques petites annotations et le tour est joué. Cependant, leurs limites sont vite atteintes car, comme toute méthode au sein d’une entité, les callbacks n’ont accès à aucune information de l’extérieur.

2. Event subscribers vs Entity listeners

Pour exécuter des actions plus complexes lors d’évènements, il faut créer des services. L’idée est vraiment la même, au lieu d’une méthode callback dans notre entité, on a un service défini hors de notre entité. La seule différence est la syntaxe bien sûr.

use Doctrine\ORM\Events;
use Doctrine\Common\EventSubscriber;
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;

class LocalizationPersister implements EventSubscriber
{
     public function __construct(\Swift_Mailer $mailer)
     {
        $this->mailer = $mailer;
     }

    public function getSubscribedEvents()
    {
        return [Events::prePersist];
    }
    
    public function onPrePersist(LifecycleEventArgs $args)
    {
        $entity = $args->getObject(); //  l'entité sur laquelle l'évènement est en train de se produire
        
        if (!$entity instanceof Article) {
            return;
        }
        
        $message = new \Swift_Message();
        $message->addTo($entity->getAuthor());
        $this->mailer->send($message);
        
        $entityManager = $args->getObjectManager(); // on peut également récupérer une instance d'EntityManager pour persister ou supprimer de nouvelles entités que vous pourriez gérer
    }
}

Il y a tout de même un point qui diffère des callbacks, c’est que nos services seront exécutés pour un évènement concernant toutes nos entités, et non attaché à une seule entité, d’où la vérification sur le type d’entité.

Les Event Listeners ressemeblent beaucoup aux subscribers à 2 détails près:

  • les events listeners nécessitent d’être taggués manuellement
  • les events listeners sont instanciés lazily uniquement quand ils sont utilisés, donc dans la mesure du possible préférez les events listeners aux subscribers
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;

class BlogLogoListener
{
    public function preRemove(Logo $logo, LifecycleEventArgs $args)
    {
        unlink($logo->getPath());
    }
}
services:
    blog_logo_listener:
        class: App\Listener\BlogLogoListener
        tags:
            - { name: doctrine.orm.entity_listener, event: preRemove, entity: App\Entity\Logo }

Update

Est-ce que vous savez ce qu’il se passe sous la capot lorsque vous mettez à jour une entité ?

$entity->setField('Doctrine');
$entityManager->flush($entity); // ou flush() tout court

Par défaut, pas besoin de persist(). Doctrine va regarder si chaque champ de chaque entité a été modifié. Il est possible de changer cela, c’est-à-dire la tracking policy.

Les tracking policies

  1. Deferred Implicit - stratégie par défaut. Doctrine garde en mémoire les valeurs récupérées de la BDD et lors du flush()va comparer chaque champ des entités qu’il connaît pour voir s’il a été modifié. Cette tracking policy peut utiliser beaucoup de ressources.
  2. Deferred Explicit - seules les entités explicitement persistées $entityManager->persist($entity) ont leurs champs comparés. Cette tracking policy utilise moins de ressources, mais attention aux cascades: cascade: {"persiste"} dans les annotations ne suffit pas, il faut appeler $entityManager->persist(); sur chaque entité.
  3. Notify - chanque entité doit signaler ses modifications à un listener. Cette tracking policy est la plus optimisée, mais lourde à mettre en place.
/**
 * @ORM\Entity
 * @ORM\ChangeTrackingPolicy("DEFERRED_EXPLICIT")
 */
class Article
{
    // ...
}

Listeners: particularité de l’update

Seuls les champs déjà modifiés peuvent être remodifiés depuis un listener. Si vous souhaitez modifier une autre propriété, il faut lancer manuellement une comparaison.

use Doctrine\Common\Persistence\Event\PreUpdateEventArgs;

class BlogListener
{
    public function preUpdate(Blog $blog, PreUpdateEventArgs $args)
    {
        if($args->hasChangedField('name') && $args->getNewValue('name')) {
            $args->setNewValue('name', 'new name: '.$args->getNewValue());
            
            $blog->setSlug('new slug');
            // on relance une comparaison
            $classMetadata = $args->getEntityManager()->getClassMetadata(Blog::class);
            $args->getEntityManager()->getUnitOfWork()->recomputeSingleEntityChangeSet($classMetadata, $blog);
        }
    }
}

Il est impossible de créer de nouvelles entités ici. Si vous souhaitez créer une nouvelle entité quand une autre entité est mise à jour, vous devez créer un EventSubscriber.

Un piège: suivi des objets

Par défaut, Doctrine compare les valeurs des propriétés pour détecter si elles ont été modifiées.

Cela ne marche pas avec les objets: UploadedFile, DateTime, etc. Il faut alors soit modifier la valeur d’une autre propriété, d’un type primitif, soit utiliser des objets immutables.

Mapping & configuration

Embeddables

Embeddables sont des classes qui ne sont pas des entités, mais qui peuvent être requêtées en DQL. Embeddables sont principalement utilisées afin d’éviter la duplication de code. Ils permettent également de regrouper certains champs d’une entité dans une classe à part et d’éviter ainsi qu’il y ait trop de champs dans une même entité.

Un exemple de code vaut mieux qu’un long discours:

/** @ORM\Embeddable */
class Address
{
    /** @ORM\Column() */
    private $street;
    
    /** @ORM\Column() */
    private $city;
}

/** @ORM\Entity */
class Blog
{
    /** @ORM\Embeddable(class="Address") */
    private $address;
    
    public function __construct()
    {
        $this->address = new Address();
    }
}

Pour requêter en DQL, on pourra écrire: SELECT b FROM Blog b WHERE b.address.city = ...

Les champs de l’embeddable Address vont être ajoutés par Doctrine dans la table blog comme s’ils faisaient partie de l’entité Blog. Par défaut, Doctrine les préfixera par le nom de l’embeddable (address_street, address_city).

Ajouter un type Doctrine

Avec Doctrine il est possible de faire un type personnalisé. Imaginons qu’on souhaite stocker les dates en BDD au format UTC. La première idée et la plus simple est d’utiliser les setters/getter de notre entité pour lire ou écrire simultanément un datetime et une colonne timezone. Une solution plus élégante est de créer un type Doctrine qui va se charger de la conversion en UTC:

namespace App\Type;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;

class DateTimeUtcType extends Type
{
    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
    {
        return $platform->getDateTimeTypeDeclarationSQL($fieldDeclaration);
    }
    
    public function convertToDatabaseValue($value, AbstractPlatform $platform) {}
    public function convertToPHPValue($value, AbstractPlatform $platform) {}
    public function getName() { return 'datetime_immutable_utc'; }
}
# config/packages/doctrine.yaml
doctrine:
    dbal:
        types:
            datetime_utc:  App\Type\DateTimeUtcType

Il ne reste plus qu’à mettre le type qu’on vient de créer dans notre entité:

/**
 * @ORM\Entity
 */
class Blog
{
    /**
     * @ORM\Column(type="datetime_utc")
     */
    private $datetimeUtc;

Caches

Doctrine propose trois types de caches:

  • Query Cache qui permet de mettre en cache les transformations de requêtes DQL en SQL
  • Metadata Cache qui permet de ne parser qu’une seule fois les metadatas des classes d’entités depuis XML, YAML, Annotaions, etc…
  • Result Cache qui permet de mettre en cache le résultat d’une requête

Query Cache et Metadata Cache sont indispensables en prod et activés par Symfony Flex par défaut.

# config/packages/doctrine.yaml
doctrine:
    orm:
        metadata_cache_driver: apcu
        query_cache_driver: apcu
        // optionnel et manuel
        result_cache_driver: apcu

Il est également possible d’activer le Second Level Cache de Doctrine afin de réduire le nombre de requêtes envoyées depuis l’application. Cette fonctionnalité est expérimentale, donc à utiliser avec précaution.

Etendre Doctrine

  • stof/doctrine-extension-bundle installe et configure 11 extensions de Doctrine (Sortable, SoftDeletable, Sluggable, etc.)
  • ramsey/uuid-doctrine pour les UUID
  • steevanb/doctrine-stats enrichit le profiler de Symfony

Mikhail M.

Ajouter un commentaire