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...
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.
/** @ORMEntity */
class Article
{
/**
* @ORMManyToOne(targetEntity="Blog", inversedBy="articles")
*/
private $blog;
}
/** @ORMEntity */
class Blog
{
/**
* @ORMOneToMany(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.
/** @ORMEntity */
class Article
{
/**
* @ORMManyToOne(targetEntity="Blog", inversedBy="articles")
*/
private $blog;
}
/** @ORMEntity */
class Blog
{
/**
* @ORMOneToMany(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.
/** @ORMEntity */
class Article
{
/**
* @ORMManyToOne(targetEntity="Blog", inversedBy="articles")
*/
private $blog;
}
/** @ORMEntity */
class Blog
{
/**
* @ORMOneToMany(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.
/** @ORMEntity */
class Article
{
/**
* @ORMManyToOne(targetEntity="Blog", inversedBy="articles")
*/
private $blog;
}
/** @ORMEntity */
class Blog
{
/**
* @ORMOneToMany(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();
// ...
}
}
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 DoctrineORMProxyProxy) {
$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: AppFilterNoDraftFilter
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();
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 DoctrineCommonPersistenceEventPreUpdateEventArgs;
/**
* @ORMEntity
* @ORMHasLifecycleCallbacks
*/
class Blog
{
/** @ORMPreUpdate */
public function onPreUpdate(PreUpdateEventArgs $event)
{
$this->updatedAt = new DateTimeImmutable();
}
}
36. Events listeners/subscriber (2/3)
use DoctrineORMEvents;
use DoctrineCommonEventSubscriber;
use DoctrineCommonPersistenceEventLifecycleEventArgs;
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 DoctrineCommonPersistenceEventLifecycleEventArgs;
class BlogLogoListener
{
public function preRemove(Logo $logo, LifecycleEventArgs $args)
{
unlink($logo->getPath());
}
}
services:
blog_logo_listener:
class: AppListenerBlogLogoListener
tags:
- { name: doctrine.orm.entity_listener, event: preRemove, entity: AppEntityLogo }
3
Syntaxe avec Doctrine 2.5+.
❗
La documentation n'est pas très claire pour l'instant.
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
/**
* @ORMEntity
* @ORMChangeTrackingPolicy("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 DoctrineCommonPersistenceEventPreUpdateEventArgs;
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
* @ORMColumn(type="datetime_immutable")
*/
private $updatedAt;
}
47. Embeddables
/** @ORMEmbeddable */
class Address
{
/** @ORMColumn() */
private $street;
/** @ORMColumn() */
private $city;
}
/** @ORMEntity */
class Blog
{
/** @ORMEmbedded(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 DoctrineDBALPlatformsAbstractPlatform;
use DoctrineDBALTypesConversionException;
use DoctrineDBALTypesType;
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: AppTypeDatetimeUtcType
Exemple, pour stocker une date avec un timezone (en MySQL...) :
/** ORMEntity */
class Article
{
/** ORMColumn(type="datetime_utc") */
private $utcDate;
/** ORMColumn(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.
/** @ORMMappedSuperclass */ /** @ORMEntity */
abstract class AbstractUser class User extends AbstractUser
{ {
/** @ORMColumn() */ /** @ORMId @ORMColumn(type="integer") */
protected $email; private $id;
/** @ORMColumn() */ /** @ORMColumn() */
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 AppEntityBlog b');
$query->useResultCache(true, 3600); // 1 heure
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