Doctrine en dehors des sentiers battus

Doctrine en dehors des sentiers battus

L'ORM Doctrine offre beaucoup plus de flexibilité qu'il n'y paraît. Dans cette présentation, nous allons nous intéresser à son fonctionnement interne et à ses fonctionnalités moins connues, pour découvrir comment mieux l'utiliser. Au programme, évènements et listeners, filtres, tracking policy, mais aussi des astuces sur des architectures possibles pour son code...

Présenté au SymfonyLive Lille 2019

88a681988c6744a099be88084dedb545?s=128

Romaric Drigon

March 01, 2019
Tweet

Transcript

  1. Doctrine en dehors des sentiers ba!us

  2. Romaric Drigon, Développeur et consultant à netinfluence @romaricdrigon / romaric@netinfluence.ch

  3. ❗ = à utiliser avec précaution

  4. SELECT Que se passe-t-il lorsque je requête une entité?

  5. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1);

  6. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité
  7. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il regarde dans l'identityMap si l'entité n'a pas déjà été chargée
  8. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il 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
  9. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il 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
  10. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il 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)
  11. Une requête basique $blog = $entityManager->getRepository(Blog::class)->find(1); — Doctrine ORM regarde

    dans le mapping (ClassMetadata) quel est l'identifiant (ID) de notre entité — dans le cas d'une requête par ID, il 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) — elle sera ajoutée à l'identityMap, puis retournée
  12. Changer l'hydratation Plusieurs modes sont disponibles : $result = query->getResult(Query::HYDRATE_OBJECT);

    // Blog, objet construit par Reflection $result = query->getResult(Query::HYDRATE_ARRAY); // tableau associatif avec id, name... $result = query->getResult(Query::HYDRATE_SCALAR); // tableaux avec b_id, b_name... (non-dédupliqué !) $result = query->getResult(Query::HYDRATE_SINGLE_SCALAR); // non supporté ici $result = query->getResult(Query::HYDRATE_SIMPLEOBJECT); // Blog mais sans objets joints (1-to-1) (!) Pourquoi? Principalement pour des raisons de performance, l'hydratation en objet, surtout avec des objets associés, est très lourde. ❗ pour HYDRATE_SIMPLEOBJECT, surtout si vos getters sont typés
  13. Une autre optimisation : partial object // Dans BlogRepository $query

    = $this->createQueryBuilder('b') ->select('PARTIAL b.{id, name}') ->where('b.name = :name') ->setParameter('name', 'Romaric') ->getQuery(); $blogs = query->getResult(); dump($blogs[0] instanceof Blog); // true dump($blogs[0]->getId()); // 1 dump($blogs[0]->getName()); // Romaric dump($blogs[0]->getDescription()); // null ❗ Tous les autres champs seront null, les associations et collections auront des proxys non initialisés / vides.
  14. Ou encore : les Partial 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]);
  15. Relations / associations : base Dans une relation, il y

    a l'owning side (requis), et l'inverse side. /** @ORM\Entity */ class Article { /** * @ORM\ManyToOne(targetEntity="Blog", inversedBy="articles") */ private $blog; } /** @ORM\Entity */ class Blog { /** * @ORM\OneToMany(targetEntity="Article", mappedBy="blog", * cascade={"remove"}, * onDelete="CASCADE", * orphanRemoval=true) */ private $articles; }
  16. Relations / associations : base Dans une relation, il y

    a l'owning side (requis), et l'inverse side. /** @ORM\Entity */ class Article { /** * @ORM\ManyToOne(targetEntity="Blog", inversedBy="articles") */ private $blog; } /** @ORM\Entity */ class Blog { /** * @ORM\OneToMany(targetEntity="Article", mappedBy="blog", * cascade={"remove"}, * onDelete="CASCADE", * orphanRemoval=true) */ private $articles; }
  17. Relations / associations : base Dans une relation, il y

    a l'owning side (requis), et l'inverse side. /** @ORM\Entity */ class Article { /** * @ORM\ManyToOne(targetEntity="Blog", inversedBy="articles") */ private $blog; } /** @ORM\Entity */ class Blog { /** * @ORM\OneToMany(targetEntity="Article", mappedBy="blog", * cascade={"remove"}, * onDelete="CASCADE", * orphanRemoval=true) */ private $articles; }
  18. Relations / associations : base Dans une relation, il y

    a l'owning side (requis), et l'inverse side. /** @ORM\Entity */ class Article { /** * @ORM\ManyToOne(targetEntity="Blog", inversedBy="articles") */ private $blog; } /** @ORM\Entity */ class Blog { /** * @ORM\OneToMany(targetEntity="Article", mappedBy="blog", * cascade={"remove"}, * onDelete="CASCADE", * orphanRemoval=true) */ private $articles; }
  19. 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 de(s) entité(s) jointe(s). 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
  20. Lazy-loading... $blogs = $entityManager->getRepository(Blog::class)->findAll(); // Chaque Blog a des articles

    (One-to-Many), // et des contributeurs/authors (One-to-Many) foreach ($blogs as $blog) { foreach ($blog->getArticles() as $article) { $title = $article->getTitle(); // ... } foreach ($blog->getAuthors() as $author) { $name = $author->getName(); // ... } }
  21. ...et kaboom, problème du N+1

  22. N+1 : une possible solution On peut demander à Doctrine

    de récupérer en même temps les articles et les contributeurs : $blogs = $this->createQueryBuilder('blog') ->addSelect('article, author') ->join('blog.articles', 'article') ->join('blog.authors', 'author') ->getQuery() ->getResult(); ❗ on garde le problème du coût de l'hydratation (coût en O(n*m*q) ici, avec n blogs, m articles et q contributeurs).
  23. Solution2 : multi-step hydratation $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 2 Plus de détails sur le blog de Marco Pivetta (Ocramius), exemples sur Github ici
  24. ❗ Note sur les proxies // Un Blog a un

    Logo (One-To-One, owning side) $logo = $blog->getLogo(); // Les proxies supportent mal la sérialisation $str = serialize($logo); $logo2 = unserialize($str); // ! Error at offset 0... // À la place, soit charger le proxy if ($logo instanceof Doctrine\ORM\Proxy\Proxy) { $logo->__load(); } $logo2 = unserialize(serialize($logo)); // // Soit utiliser l'identité $logoId = unserialize(serialize($logo->getId()));
  25. Requêtage, astuce : les Criteria 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 générée, sinon le filtrage aura lieu sur les éléments en mémoire.
  26. Astuce 2 : appliquer un filtre à toutes les requêtes

    class NoDraftFilter extends SQLFilter { public function addFilterConstraint(ClassMetadata $entityMetadata, $targetTableAlias) { if (Article::class !== $entityMetadata->reflClass->getName()) { return ''; } return $targetTableAlias.'.status != \'draft\''; // DQL qui sera injecté dans le WHERE } } orm: entity_managers: default: filters: draft_filter: class: App\Filter\NoDraftFilter enabled: true $publishedArticles = $entityManager->getRepository(Article::class)->findAll(); $entityManager->getFilters()->disable('draft_filter'); $allArticles = $entityManager->getRepository(Article::class)->findAll();
  27. Astuce 3 : organiser ses repositories... class ArticleRepository { public

    function findOnlineArticlesIWroteOnBlog(User $user, Blog $blog) { $queryBuilder = $this->createQueryBuilder('a'); self::withIsOnline($queryBuilder, 'a'); self::withIWrote($queryBuilder, 'a', $user); self::withFromBlog($queryBuilder, 'a', $blog); return $queryBuilder->getQuery()->getResult(); } private static function withIsOnline(QueryBuilder $queryBuilder, string $alias) { $queryBuilder ->andWhere($alias.'.status != \'draft\'') ->andWhere($alias.'.publishOn >= CURRENT_TIMESTAMP()') ; } // withIWrote(), withFromBlog(), etc }
  28. ...ou construire des requêtes complexes class ArticleQueryBuilderBuilder { private $queryBuilder;

    private $tableAlias; public function __construct(QueryBuilder $queryBuilder, string $tableAlias) { $this->queryBuilder = $queryBuilder; $this->tableAlias = $tableAlias; } public function withIsOnline() { $this->queryBuilder ->andWhere($this->tableAlias.'.status != \'draft\'') ->andWhere($this->tableAlias.'.publishOn >= CURRENT_TIMESTAMP()'); } // withIWrote(), withFromBlog(), etc } $articles = $entityManager->getRepository(Article::class) ->getArticleQueryBuilderBuilder() // À ajouter dans ArticleRepository ->withIsOnline() ->withIWrote($user) ->withFromBlog($blog) ->getQueryBuilder()->getQuery()->getResult();
  29. [intermède]

  30. INSERT Que se passe-t-il quand j'insère une nouvelle entité ?

  31. Cycle de Vie d'un persist() $article = new Article(); //

    Doctrine détecte qu'il ne connaît pas l'entité, // il va l'ajouter dans UnitOfWork::entityInsertions $entityManager->persist($article); // Maintenant, Doctrine va synchroniser l'UnitOfWork avec la BDD : // 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();
  32. Cycle de Vie d'un persist() $article = new Article(); //

    Doctrine détecte qu'il ne connaît pas l'entité, // il va l'ajouter dans UnitOfWork::entityInsertions $entityManager->persist($article); // Maintenant, Doctrine va synchroniser l'UnitOfWork avec la BDD : // 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();
  33. Cycle de Vie d'un persist() $article = new Article(); //

    Doctrine détecte qu'il ne connaît pas l'entité, // il va l'ajouter dans UnitOfWork::entityInsertions $entityManager->persist($article); // Maintenant, Doctrine va synchroniser l'UnitOfWork avec la BDD : // 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();
  34. Évènements Action Quand ? Évè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
  35. Lifecycle callbacks (1/3) use Doctrine\Common\Persistence\Event\PreUpdateEventArgs; /** * @ORM\Entity * @ORM\HasLifecycleCallbacks

    */ class Blog { /** @ORM\PreUpdate */ public function onPreUpdate(PreUpdateEventArgs $event) { $this->updatedAt = new \DateTimeImmutable(); } }
  36. Events listeners/subscriber (2/3) use Doctrine\ORM\Events; use Doctrine\Common\EventSubscriber; use Doctrine\Common\Persistence\Event\LifecycleEventArgs; class

    LocalizationPersister implements EventSubscriber { public function getSubscribedEvents() { return [Events::prePersist]; } public function onPrePersist(LifecycleEventArgs $args) { if (!$args->getObject() instanceof Article) { return; } $args->getObject()->setLocale('fr'); // ou injectée... } }
  37. Entity listeners3 ⭐ (3/3) 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 } 3 Syntaxe avec Doctrine 2.5+. ❗ La documentation n'est pas très claire pour l'instant.
  38. UPDATE Et lors de la mise à jour d'une entité

    ?
  39. Lors du flush() $article->setTitle('Retex SymfonyLive 2019'); $entityManager->flush($article); // 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.
  40. Les tracking policies 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é. ❗ Peut utiliser beaucoup de ressource. Deferred Explicit : seules les entités explicitement persistées ($entityManager->persist($article)) ont leurs champs comparés. ❗ Utilise moins de ressources, mais attention aux cascades. Notify : chaque entité doit signaler ses modifications à un listener. Le plus optimisé, mais lourd à mettre en place.
  41. Exemple avec Deferred explicit /** * @ORM\Entity * @ORM\ChangeTrackingPolicy("DEFERRED_EXPLICIT") */

    class Article { // ... } $article->setTitle('Hello World'); $entityManager->persist($article); $entityManager->flush(); ❗ Il faut appeler exactement $entityManager->persist() sur chaque entité, cascade: {"persist"} dans les annotations ne suffit pas. Donc il faut pouvoir/vouloir accéder à chaque entité depuis son contrôleur...
  42. Listeners : particularité de l'update use Doctrine\Common\Persistence\Event\PreUpdateEventArgs; class BlogListener {

    public function preUpdate(Blog $blog, PreUpdateEventArgs $args) { if ($eventArgs->hasChangedField('name') && $eventArgs->getNewValue('name')) { // Attention de cette manière seuls les champs déjà modifiés peuvent être remodifiés $eventArgs->setNewValue('name', 'Nouveau nom: '.$eventArgs->getNewValue()); $blog->setSlug(canonicalize($blog->getName())); // Si on souhaite modifier une autre propriété, il faut lancer une recomparaison $classMetadata = $args->getEntityManager()->getClassMetadata(Blog::class); $args->getEntityManager()->getUnitOfWork()->recomputeSingleEntityChangeSet($classMetadata, $blog); } } } ❗ On ne peut pas créer de nouvelles entités ici.
  43. Listeners : créer de nouvelles entités class BlogSubscriber implements EventSubscriber

    { public function getSubscribedEvents() { return [Events::onFlush]; } public function onFlush(OnFlushEventArgs $args) { $entityManager = $args->getEntityManager(); $unitOfWork = $entityManager->getUnitOfWork(); foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) { if ($entity instanceof Blog) { $changeset = $unitOfWork->getEntityChangeSet($entity); if (isset($changeset['name'])) { $notification = new Notification(); // etc $entityManager->persist($notification); // On ne peut pas déclencher de flush() - on doit recompute "à la main" $classMetadata = $entityManager->getClassMetadata(Notification::class); $unitOfWork->computeChangeSet($classMetadata, $notification); } } } } }
  44. 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... Il faut alors soit modifier la valeur d'une autre propriété, d'un type primitif, soit utiliser des objets immutables. class Article { /** * @var \DateTimeImmutable * @ORM\Column(type="datetime_immutable") */ private $updatedAt; }
  45. None
  46. MAPPING & Configuration & dernière astuces

  47. Embeddables /** @ORM\Embeddable */ class Address { /** @ORM\Column() */

    private $street; /** @ORM\Column() */ private $city; } /** @ORM\Entity */ class Blog { /** @ORM\Embedded(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 = ...
  48. Ajouter un type Doctrine (1/2) 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) { if (null === $value) { return $value; } if (!$value instanceof \DateTime) { throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'DateTime']); } $value->setTimezone(new \DateTimeZone('UTC')); return $value->format($platform->getDateTimeFormatString()); } public function convertToPHPValue($value, AbstractPlatform $platform) { if ($value === null || $value instanceof \DateTime) { return $value; } $val = \DateTime::createFromFormat($platform->getDateTimeFormatString(), $value, new \DateTimeZone('UTC')); if (!$val) { throw ConversionException::conversionFailedFormat($value, $this->getName(), $platform->getDateTimeFormatString()); } return $val; } public function getName() { return 'datetime_utc'; } }
  49. Ajouter un type Doctrine (2/2) doctrine: dbal: types: datetime_utc: App\Type\DatetimeUtcType

    Exemple, pour stocker une date avec un timezone (en MySQL...) : /** ORM\Entity */ class Article { /** ORM\Column(type="datetime_utc") */ private $utcDate; /** ORM\Column(type="string") */ private $timezone; public function getDate() { return (clone $this->utcDate)->setTimezone(new \DateTimeZone($this->timezone)); } public function setDate(\DateTime $date) { $this->timezone = $date->getTimezone()->getName(); $this->utcDate = clone $date; } }
  50. Héritage Cas typique : définir dans un bundle une entité

    qui sera étendue. /** @ORM\MappedSuperclass */ /** @ORM\Entity */ abstract class AbstractUser class User extends AbstractUser { { /** @ORM\Column() */ /** @ORM\Id @ORM\Column(type="integer") */ protected $email; private $id; /** @ORM\Column() */ /** @ORM\Column() */ protected $username; private $myfield; } } ❗ aux limites (associations...) Doctrine supporte les Traits, cela est généralement plus simple. ❗ ❗ ❗ pour Single Table Inheritance et Multiple Table Inheritance
  51. Caches doctrine: orm: # Ces 2 caches sont INDISPENSABLES en

    "prod" : # (et activés par Symfony Flex par défaut) metadata_cache_driver: apcu # cache les annotations query_cache_driver: apcu # cache le DQL généré # Optionnel et manuel : result_cache_driver: apcu Le Result cache devra être rempli manuellement : $query = $em->createQuery('SELECT b FROM App\Entity\Blog b'); $query->useResultCache(true, 3600); // 1 heure
  52. Second-level cache1 doctrine: orm: entity_managers: default: second_level_cache: enabled: true region_cache_driver:

    apcu regions: my_entity_region: type: apcu /** * @ORM\Entity * @ORM\Cache(usage="READ_ONLY", region="my_entity_region") */ class Blog { // ... } 1 Il y a un nombre important d'options, voir la documentation
  53. Étendre Doctrine Le plus connu, les Doctrine extensions : Sortable,

    SoftDeleteable, Sluggable... ⭐ ⭐ steevanb/DoctrineStatsBundle ⭐ ⭐ Des fonctions DQL supplémentaires : - spécifiques à chaque plate-forme - pour requêter/manipuler du JSON - pour les types spatiaux Des utilitaires pour les batchs
  54. Conclusion & questions @romaricdrigon / romaric@netinfluence.ch