lundi 31 octobre 2016

Correction de PersistentCollection::clear()

Jusqu'à la version 2.5.5, PersistentCollection::clear() ne reset pas les clefs de $this->collection, si la collection est vide.

Ce qui veut dire que si on a eu des éléments dans $this->collection à un moment, puis qu'ils ont été supprimés "un à un" sans passer par clear(), la prochaine clef ne sera pas 0 mais l'index courant du tableau + 1 (2 si on avait 2 éléments par exemple).

Ce comportement pourrait être justifié, mais comme les clefs sont reset uniquement si on a des éléments à supprimer, le comportement dans les 2 cas n'est pas le même :
  • si on n'a pas d'éléments à supprimer, les clefs ne sont pas reset
  • si on a des éléments à supprimer, les clefs sont reset
$bar = new Entity(); $bar2 = new Entity(); $foo = new PersistentCollection(); $foo->add($bar); $foo->add($bar2); // les clefs seront reset, le prochain appel à add() aura l'index 0 $foo->clear(); $foo->add($bar); // [0] var_dump(array_keys($foo->toArray())); $foo = new PersistentCollection(); $foo->add($bar); $foo->add($bar2); $foo->removeElement($bar); $foo->removeElement($bar2); // les clefs ne seront pas reset, le prochain appel à add aura l'index suivant $foo->clear(); // [2] var_dump(array_keys($foo->toArray()));

PersistentCollection.php#536
Pull request #6110

mercredi 5 octobre 2016

Comment bien coder une manyToOne bidirectionnelle

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).
  • 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
Plus d'informations sur les liaisons 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