Slide 1

Slide 1 text

Doctrine en dehors des sentiers ba!us

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

❗ = à utiliser avec précaution

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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é

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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)

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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.

Slide 14

Slide 14 text

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]);

Slide 15

Slide 15 text

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; }

Slide 16

Slide 16 text

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; }

Slide 17

Slide 17 text

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; }

Slide 18

Slide 18 text

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; }

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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(); // ... } }

Slide 21

Slide 21 text

...et kaboom, problème du N+1

Slide 22

Slide 22 text

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).

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

❗ 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()));

Slide 25

Slide 25 text

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.

Slide 26

Slide 26 text

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();

Slide 27

Slide 27 text

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 }

Slide 28

Slide 28 text

...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();

Slide 29

Slide 29 text

[intermède]

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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();

Slide 32

Slide 32 text

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();

Slide 33

Slide 33 text

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();

Slide 34

Slide 34 text

É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

Slide 35

Slide 35 text

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(); } }

Slide 36

Slide 36 text

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... } }

Slide 37

Slide 37 text

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.

Slide 38

Slide 38 text

UPDATE Et lors de la mise à jour d'une entité ?

Slide 39

Slide 39 text

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.

Slide 40

Slide 40 text

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.

Slide 41

Slide 41 text

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...

Slide 42

Slide 42 text

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.

Slide 43

Slide 43 text

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); } } } } }

Slide 44

Slide 44 text

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; }

Slide 45

Slide 45 text

No content

Slide 46

Slide 46 text

MAPPING & Configuration & dernière astuces

Slide 47

Slide 47 text

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 = ...

Slide 48

Slide 48 text

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'; } }

Slide 49

Slide 49 text

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; } }

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

É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

Slide 54

Slide 54 text

Conclusion & questions @romaricdrigon / romaric@netinfluence.ch