Doctrine n'est pas forcément un ORM facile à utiliser quand on débute et afin de ne pas tomber dans des pièces voici comment créer une ManyToOne facilement.
Vous avez sans doute déjà entendu parler de l'ORM (Object Relational Mapper) Doctrine et de ses liaisons que l'on peut y faire (oneToOne, manyToOne, oneToMany ou manyToMany). Mais savez-vous comment programmer ces liaisons dans vos entités ?
Avant de commencer il est intéressant que l'on se mette d'accord sur le vocabulaire qui sera utilisé par la suite :
Il y a une chose très importante à savoir dans Doctrine. Il ne gère QUE le owning side car c'est ce côté qui contient la clé étrangère. Le inverse side n'est là que pour vous, afin que vous puissiez visualiser facilement les différentes liaisons.
J'ai mis parfaite entre guillemets car cela va correspondre à 99% de vos liaisons.
Comme Doctrine ne gère que le owning side, il se peut que vous ayez des bugs dans vos entités car vous pensez qu'il va sauvegarder les données côté inverse side lors du persist alors qu'en fait, comme ce n'est là que pour faire joli, vous vous retrouviez avec aucune donnée, un null dans le owning side ou des données orphelines dans la table inverse side.
Prenons par exemple un blog avec des articles et des commentaires. Ce qui nous fait :
class Article
{
/** @var Collection|Comment[] */
protected $comments;
public function __construct()
{
$this->comments = new ArrayCollection();
}
public function setComments(iterable $comments): self
{
$this->clearComments();
/** @var Comment $comment */
foreach ($comments as $comment) {
$this->addComment($comment);
}
return $this;
}
public function addComment(Comment $comment): self
{
if (false === $this->comments->contains($comment)) {
$this->comments->add($comment);
$comment->setArticle($this);
}
return $this;
}
/** @return Collection|Comment[] */
public function getComments(): Collection
{
return $this->comments;
}
public function removeComment(Comment $comment): self
{
if ($this->comments->contains($comment)) {
$this->comments->removeElement($comment);
$comment->setArticle(null);
}
return $this;
}
public function clearComments(): self
{
foreach ($this->getComments() as $comment) {
$this->removeComment($comment);
}
$this->comments->clear();
return $this;
}
}
class Article
{
/** @var Collection|Comment[] */
protected $comments;
public function __construct()
{
$this->comments = new ArrayCollection();
}
public function setComments(iterable $comments): self
{
$this->clearComments();
/** @var Comment $comment */
foreach ($comments as $comment) {
$this->addComment($comment);
}
return $this;
}
public function addComment(Comment $comment): self
{
if (false === $this->comments->contains($comment)) {
$this->comments->add($comment);
$comment->setArticle($this);
}
return $this;
}
/** @return Collection|Comment[] */
public function getComments(): Collection
{
return $this->comments;
}
public function removeComment(Comment $comment): self
{
if ($this->comments->contains($comment)) {
$this->comments->removeElement($comment);
$comment->setArticle(null);
}
return $this;
}
public function clearComments(): self
{
foreach ($this->getComments() as $comment) {
$this->removeComment($comment);
}
$this->comments->clear();
return $this;
}
}
Notre propriété $comments est à la fois une Collection et un tableau de Comment afin d'avoir l'auto-complétion des deux classes. Collection est utile car il est compatible avec ArrayCollection et PersistentCollection qui vous seront retournés par Doctrine.
Dans le __construct il est important d'initialiser $comments avec ArrayCollection sinon nous ne pourrons jamais ajouter de commentaires à notre article. Impossible de l'initialiser avec Collection car, comme son nom ne l'indique pas du tout, c'est une interface.
setComments ne fait pas de $this->comments = new ArrayCollection(); car les commentaires qui existent déjà sur notre article ne seront jamais supprimés. C'est pour cela qu'il y a les méthodes removeComment et clearComments.
clearComments ne doit pas non plus faire $this->comments = new ArrayCollection(); pour la même raison. Par contre, il faut bien penser à faire un clear pour remettre le pointeur du tableau à zéro.
Dans le addComment il est intéressant de noter deux choses. La première qu'il faut d'abord vérifier que notre Collection ne contient pas déjà notre commentaire et surtout, comme dit plus haut, comme le inverse side n'est là que pour faire joli, il faut notifier le owning side qu'il est lié à un article.
Comme pour le addComment, dans le removeComment il faut notifier le owning side qu'il n'est plus lié à un article avec $comment->setArticle(null);. Celui-ci sera supprimé grâce à la propriété orphanRemoval.
Article:
oneToMany:
comments:
targetEntity: Comment
mappedBy: article
orphanRemoval: true
cascade: [persist, remove]
Article:
oneToMany:
comments:
targetEntity: Comment
mappedBy: article
orphanRemoval: true
cascade: [persist, remove]
Comme nous comme inverse side, il faut mettre mappedBy pour indiquer avec quelle propriété notre entité est liée.
orphanRemoval permet de supprimer automatiquement les relations qui sont à null. Côté logique, il devrait être plutôt placé du coté owning side mais non, c'est bien ici qu'il faut le placer.
cascade persist permet de déclencher la sauvegarde des commentaires lors de la sauvegarde d'un article.
cascade remove permet de déclencher la suppression des commentaires lors de la suppression d'un article.
class Comment
{
/** @var ?Article */
protected $article;
public function setArticle(?Article $article): self
{
$this->article = $article;
if ($article instanceof Article) {
$article->addComment($this);
}
return $this;
}
public function getArticle(): ?Article
{
return $this->article;
}
}
class Comment
{
/** @var ?Article */
protected $article;
public function setArticle(?Article $article): self
{
$this->article = $article;
if ($article instanceof Article) {
$article->addComment($this);
}
return $this;
}
public function getArticle(): ?Article
{
return $this->article;
}
}
Dans setArticle on regarde si $article est bien une instance de la classe Article pour eviter une boucle infinie lors des notifications.
Comment:
manyToOne:
article:
targetEntity: Article
inversedBy: comments
joinColumn:
nullable: false
Comment:
manyToOne:
article:
targetEntity: Article
inversedBy: comments
joinColumn:
nullable: false
Comme nous sommes du coté owning side, il faut renseigner la propriété inversedBy.
Le joinColumn à null est nécessaire car par defaut, Doctrine laisse la possibilité de mettre une clé étrangère à null. Et c'est grâce à cela que orphanRemoval va pouvoir faire son travail et supprimer les liaisons inutiles.