Comment programmer une ManyToOne sous Doctrine

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.

Jérémy 🤘
Jérémy 🤘

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 ?

Vocabulaire

Avant de commencer il est intéressant que l'on se mette d'accord sur le vocabulaire qui sera utilisé par la suite :

  • owning side : Côté propriétaire, là où la clé étrangère sera stockée. Toujours du coté many.
  • inverse side : Côté inverse. Toujours du coté one.
  • manyToOne : Mon entité A contient une ou plusieurs données de mon entité B. Je suis donc le owning side.
  • oneToMany : Mon entité B est donc reliée à une ou plusieurs données de mon entité A. Je suis donc le inverse side.
  • oneToOne : Chacune de mes deux entités ne contient qu'une seule donnée de chaque. Comme le owning side est toujours du côté many, logiquement elles devraient être toutes les deux inverse side. Sauf que pour faire la liaison, il faut choisir qui sera le owning side et qui sera le inverse side.
  • manyToMany : Chacune de mes deux entités contient une ou plusieurs données de chaque. Elles sont donc toutes les deux le owning side grâce à une table associative entre les deux. C'est donc en faite deux manyToOne vers la table associative. Mais cela est invisible pour vous.

Owning side dans Doctrine

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.

Programmer une liaison "parfaite"

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 :

  • Article > oneToMany > Comment
  • Comment > manyToOne > Article

oneToMany

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

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

manyToOne

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

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

Source