Doctrine2 permet de lier ses entités via 2 types de liaisons : manyToOne et oneToOne.
La liaison manyToMany n'est qu'un raccourci d'une entité A vers une entité "invisible" via une manyToOne, puis une oneToMany vers votre entité B.
Toutes les liaisons peuvent être unidirectionnelles (exemple : une manyToOne n'a pas de oneToMany associée) ou bidirectionnelles (exemple : une manyToOne a une oneToMany associée).
Dans le cas des liaisons bidirectionnelles, il faut définir qui est le propriétaire (owning side), et le côté inverse (inverse side).
Plus d'informations sur les liaisons Doctrine2
Doctrine2 ne gère que le owning side des relations : manyToOne, manyToMany owning side, et oneToOne owning side.
La raison est toute simple : dans votre base de données, le côté owning side est celui qui contient la clef étrangère.
Prenons pour exemple une entité User, qui est liée à une entité Comment : User > oneToMany > Comment.
Au niveau de votre base de données, c'est la table comment qui aura un user_id.
Le fait d'ajouter des Comment sur l'entité User sans appeler Comment::setUser() ne sert à rien. Doctrine2 ne sauvegardera pas votre Comment avec la liaison vers User, et conservera donc null dans Comment::$user.
Au final, une requête de ce type sera générée :
Beaucoup de bugs peuvent ne pas se voir rapidement quand on code une oneToMany : tentative d'insertion du owning side avec null dans la clef étrangère, suppression des objets côté PHP mais pas en base de données etc.
Reprenons notre exemple User > oneToMany > Comment :
La liaison manyToMany n'est qu'un raccourci d'une entité A vers une entité "invisible" via une manyToOne, puis une oneToMany vers votre entité B.
Toutes les liaisons peuvent être unidirectionnelles (exemple : une manyToOne n'a pas de oneToMany associée) ou bidirectionnelles (exemple : une manyToOne a une oneToMany associée).
Dans le cas des liaisons bidirectionnelles, il faut définir qui est le propriétaire (owning side), et le côté inverse (inverse side).
- manyToOne / oneToMany : c'est la manyToOne qui est le owning side, et la oneToMany le inverse side
- oneToOne : il faut choisir une des deux entités comme owning side, en ajoutant inversedBy côté owning side et mappedBy côté inverse side. Seule la table du owning side aura une clef étrangère vers le inverse side.
- manyToMany : il faut choisir une des deux entités comme owning side, en ajoutant inversedBy côté owning side et mappedBy côté inverse side. Une table de liaison sera créé par Doctrine2
Doctrine2 ne gère que le owning side
Doctrine2 ne gère que le owning side des relations : manyToOne, manyToMany owning side, et oneToOne owning side.
La raison est toute simple : dans votre base de données, le côté owning side est celui qui contient la clef étrangère.
Prenons pour exemple une entité User, qui est liée à une entité Comment : User > oneToMany > Comment.
Au niveau de votre base de données, c'est la table comment qui aura un user_id.
Le fait d'ajouter des Comment sur l'entité User sans appeler Comment::setUser() ne sert à rien. Doctrine2 ne sauvegardera pas votre Comment avec la liaison vers User, et conservera donc null dans Comment::$user.
Au final, une requête de ce type sera générée :
INSERT INTO comment (user_id, message) VALUES (null, 'mon commentaire')
Grace à cette exemple, on comprend qu'un inverse side (oneToMany, oneToOne inverse side, manyToMany inverse side) n'est finalement qu'un lien pratique pour le développeur, et pas un lien réellement géré par Doctrine2.
Coder la oneToMany "parfaite"
Beaucoup de bugs peuvent ne pas se voir rapidement quand on code une oneToMany : tentative d'insertion du owning side avec null dans la clef étrangère, suppression des objets côté PHP mais pas en base de données etc.
Reprenons notre exemple User > oneToMany > Comment :
class User
{
protected Collection $comments;
public function __construct()
{
$this->comments = new ArrayCollection();
}
/** @param iterable<mixed, Comment> $comments */
public function setComments(iterable $comments): static
{
$this->clearComments();
foreach ($comments as $comment) {
$this->addComment($comment);
}
return $this;
}
public function addComment(Comment $comment): static
{
if ($this->comments->contains($comment) === false) {
$this->comments->add($comment);
$comment->setUser($this);
}
return $this;
}
/** @return Collection<Comment> */
public function getComments(): Collection
{
return $this->comments;
}
public function removeComment(Comment $comment): static
{
if ($this->comments->contains($comment)) {
$this->comments->removeElement($comment);
$comment->setUser();
}
return $this;
}
public function clearComments(): static
{
foreach ($this->getComments() as $comment) {
$this->removeComment($comment);
}
$this->comments->clear();
return $this;
}
}
- Utiliser Collection de partout, sauf dans __construct() : compatibilité avec ArrayCollection et PersistentCollection, objet qu'on peut retrouver dans les formulaires via le type entity par exemple
- Les PHPDoc avec Collection|Comment[] pour avoir l'auto-complétion à la fois des méthodes de Collection, et de l'objet Comment
- setComments() ne doit surtout pas faire $this->comments = new ArrayCollection(), sinon, on ne supprime pas les liaisons du owning side Comment ! Il faut bien appeler clearComments() et addComment()
- addComment() doit vérifier que le Comment qu'on veut ajouter n'existe pas déjà, pour éviter tout doublon
- addComment() doit appeler Comment::setUser(), pour que le owning side Comment ait connaissance de la valeur à mettre dans la clef étrangère user_id
- removeComment() doit appeler Comment::setUser(), pour que le côté owning side Comment sache qu'il n'est plus lié à un User
- clearComments() ne doit pas faire $this->comments = new ArrayCollection(), sinon, on ne supprime pas les liaisons du owning side Comment ! Il faut appeler removeComment()
- clearComments() doit remettre à 0 le pointeur du tableau interne de Collection, via clear(). Sinon, le prochain appel à add() n'ajoutera pas le commentaire en clef 0, mais en clef 2 (si clearComments() a supprimé les commentaires 0 et 1 par exemple). /!\ Avant Doctrine 2.5.6, l'appel à Collection::reset() pouvait ne pas remettre à 0 les clefs, si le tableau ne contenait pas d'élément.
User:
oneToMany:
comments:
targetEntity: Comment
mappedBy: user
orphanRemoval: true
cascade: [persist, remove]
- mappedBy est obligatoire côté inverse side, pour indiquer quel est le champ lié du côté ownig side
- orphanRemoval est un peu "illogiquement placé" : il permet de dire à Doctrine2 de supprimer tous les Comment qui ont null dans Comment::$user. Cette configuration aurait peut-être due être mise côté owning side Comment, mais c'est comme ça
- cascade persist pour sauvegarder les Comment quand on sauvegarde un User
- cascade remove pour supprimer tous les Comment quand on supprime un User (à ne pas confondre avec la suppression d'un commentaire en particulier, qui s'effectue via orphanRemoval)
class Comment
{
protected ?User $user;
public function __construct()
{
$this->user = null;
}
public function setUser(User $user = null): static
{
$this->user = $user;
if ($user instanceof User) {
$user->addComment($this);
}
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
}
- Comment::$user peut être du type User, ou null, quand on a "désassocié" un Comment d'un User
- setUser() peut donc accepter une instance de User, ou null
- getUser() peut donc retourner une instance de User, ou null
Comment:
manyToOne:
user:
targetEntity: User
inversedBy: comments
joinColumn:
nullable: false
- inversedBy est obligatoire du côté owning side, si on on veut avoir une bidirectionnelle. Sinon, il ne faut pas l'indiquer.
- joinColumns.nullable doit être à false si on ne veut pas qu'un commentaire puisse avoir user_id à null
- grâce à orphanRemoval du côté inverse side, tous les Comment qui ont null dans Comment::$user seront supprimés
Merci pour cette contribution. Je l'ai faite en Live Template pour PHPStorm si jamais. http://pastebin.com/28H1NAVK
RépondreSupprimerTop, merci !
SupprimerBonjour merci pour ces explications clairs.
RépondreSupprimerCependant est-ce normal que j'obtienne une PersistentCollection quand je fais $user->getComments()
Et quand plus elle est ne contient pas d'élément, avec exactement le même mapping ?
A prioris ajouté un fetch="EAGER" reversSide me permet de récupérer les comments.
SupprimerMais est-on vraiment obligé de ne travailler qu'avec des PersistentCollection ?
Ah autant pour moi une fois le fetch="EAGER" mis c'est bien un ArrayCollection qui m'est fournis :-)
SupprimerMerci encore pour cette explication propre