Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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é le 29 mars SymfonyLive Paris 2019

Romaric Drigon

March 29, 2019
Tweet

More Decks by Romaric Drigon

Other Decks in Programming

Transcript

  1. Doctrine en dehors
    des sentiers battus

    View Slide

  2. Romaric Drigon, @romaricdrigon
    Software engineer chez à netinfluence
    !

    View Slide


  3. = à utiliser avec précaution

    View Slide

  4. SELECT
    Que se passe-t-il lorsque je sélectionne une entité ?

    View Slide

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

    View Slide

  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é

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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)

    View Slide

  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

    View Slide

  12. DQL : une abstraction de SQL
    // 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_. u.name AS u.name_0 ... FROM user u0_ WHERE u0_.name = ?
    $query->getSQL();
    $query->setParameter('name', $name);
    // User[]
    $users = query->getResult();

    View Slide

  13. DQL : une abstraction de SQL
    // 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_. u.name AS u.name_0 ... FROM user u0_ WHERE u0_.name = ?
    $query->getSQL();
    $query->setParameter('name', $name);
    // User[]
    $users = query->getResult();

    View Slide

  14. DQL : une abstraction de SQL
    // 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_. u.name AS u.name_0 ... FROM user u0_ WHERE u0_.name = ?
    $query->getSQL();
    $query->setParameter('name', $name);
    // User[]
    $users = query->getResult();

    View Slide

  15. DQL : une abstraction de SQL
    // 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_. u.name AS u.name_0 ... FROM user u0_ WHERE u0_.name = ?
    $query->getSQL();
    $query->setParameter('name', $name);
    // User[]
    $users = query->getResult();

    View Slide

  16. DQL : fonctions
    // Exemple d'utilisation de fonction DQL
    $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 = <<<'EOT'
    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')
    EOT;
    $query = $this->getEntityManager()->createQuery($dql);

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  29. Solution3
    !
    // On va aggréger côté BDD, ici avec PostgreSQL
    $sql = <<<'EOT'
    SELECT c.id, c.title,
    json_agg(
    json_build_object('id', o.id, 'title', o.product_title)
    ) AS offerings
    FROM lms__course AS c
    JOIN lms__course_offering AS o ON o.course_id = c.id
    WHERE c.is_completed = TRUE AND c.is_active = TRUE
    GROUP BY c.id
    EOT;
    $result = $databaseConnection->executeQuery($sql, $parameters);
    $courses = [];
    while ($row = $result->fetch(\PDO::FETCH_ASSOC)) {
    $offerings = json_decode($row['offerings'], true);
    $row['offerings'] = $offerings;
    $courses[] = $row;
    }

    View Slide


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

    View Slide

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

    View Slide

  32. Astuce 2 : appliquer un filtre à toutes les requêtes
    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();
    $entityManager->getFilters()->disable('draft_filter');
    $allArticles = $entityManager->getRepository(Article::class)->findAll();
    # config/packages/doctrine.yaml
    orm:
    entity_managers:
    default:
    filters:
    draft_filter:
    class: App\Filter\NoDraftFilter
    enabled: true

    View Slide

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

    View Slide

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

    View Slide

  35. View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  42. Events listeners/subscribers (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...
    }
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  51. View Slide

  52. MAPPING
    & configuration & dernières astuces

    View Slide

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

    View Slide

  54. Ajouter un type Doctrine
    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_utc'; }
    }
    # config/packages/doctrine.yaml
    doctrine:
    dbal:
    types:
    datetime_utc: App\Type\DateTimeUtcType
    Code complet sur https://git.io/fjfTP et explications https://bit.ly/2HZXnfh

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  58. Étendre Doctrine
    Le plus connu, les Doctrine extensions :
    Sortable, SoftDeleteable,
    Sluggable...
    ramsey/uuid-doctrine pour les UUID
    ⭐ ⭐
    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

    View Slide

  59. Conclusion & questions
    @romaricdrigon / [email protected]fluence.ch

    View Slide