Symfony

Symfony et la persistance bi-directionnelle des relations ManyToMany

Dans cet article, je vais vous présenter ma méthode pour rendre persistante de façon bi-directionnelle les relations ManyToMany de Symfony.

Le comportement normal d’une relation ManyToMany est d’avoir une entité maître et l’autre esclave de celle ci. En d’autre terme, lorsque l’on persiste les données il n’y a qu’un seul sens qui est sauvegardé dans la base de données.

Contexte technique

Ce tutoriel sera basé sur

  • Symfony 5.4
  • Mysql
  • Windows 11
  • Wamp64

Le seul package ajouté sera symfony/apache-pack afin d’avoir la toolbar de symfony.

L’ensemble des codes sources présentés dans cet article seront sur ce repository Github :

https://github.com/esauvage1978/Blog_manytomany_reversible

Je vous invite à suivre les consignes du fichier Readme.md afin de configurer le projet.

Définition des entités pour la relation ManyToMany de Symfony

Pour cet exemple, nous allons prendre 2 entités : Student et Event avec une relation ManyToMany.

  • 1 étudiant peut participer à plusieurs événements
  • 1 événement peut avoir plusieurs étudiants qui y participent

Les entités sont créées à l’aide des commandes :

php bin/console make:entity Student
php bin/console make:entity Event

Ce qui produit le code suivant :

<?php

namespace App\Entity;

use App\Repository\EventRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=EventRepository::class)
 */
class Event
{
    /**
    * @ORM\Id
    * @ORM\GeneratedValue
    * @ORM\Column(type="integer")
    */
    private $id;

    /**
    * @ORM\Column(type="string", length=255)
    */
    private $name;

    /**
    * @ORM\Column(type="datetime_immutable")
    */
    private $EventAt;

    /**
    * @ORM\ManyToMany(targetEntity=Student::class, inversedBy="events")
    */
    private $students;

    public function __construct()
    {
        $this->students = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    public function getEventAt(): ?\DateTimeImmutable
    {
        return $this->EventAt;
    }

    public function setEventAt(\DateTimeImmutable $EventAt): self
    {
        $this->EventAt = $EventAt;

        return $this;
    }

    /**
     * @return Collection<int, Student>
     */
    public function getStudents(): Collection
    {
        return $this->students;
    }

    public function addStudent(Student $student): self
    {
        if (!$this->students->contains($student)) {
            $this->students[] = $student;
        }

        return $this;
    }

    public function removeStudent(Student $student): self
    {
        $this->students->removeElement($student);

        return $this;
    }
}
<?php

namespace App\Entity;

use App\Repository\StudentRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=StudentRepository::class)
 */
class Student
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;

    /**
     * @ORM\ManyToMany(targetEntity=Event::class, mappedBy="students")
     */
    private $events;

    public function __construct()
    {
        $this->events = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return Collection<int, Event>
     */
    public function getEvents(): Collection
    {
        return $this->events;
    }

    public function addEvent(Event $event): self
    {
        if (!$this->events->contains($event)) {
            $this->events[] = $event;
            $event->addStudent($this);
        }

        return $this;
    }

    public function removeEvent(Event $event): self
    {
        if ($this->events->removeElement($event)) {
            $event->removeStudent($this);
        }

        return $this;
    }
}

Création des CRUD sur les entités Symfony

J’ai utilisé les commandes suivantes pour générer les crud. Pour les non initiés, CRUD signifie Create Read Update et Delele.

php bin/console make:crud Student
php bin/console make:crud Event

Afin afficher le nombre d’association, j’ai modifié les vues (Voir repository)

Mise en oeuvre de la persistance bi-directionnelle de la relation ManyToMany

Maintenant que nos 2 entités sont fonctionnelles, on peut facilement constater que lorsque je modifie un événement, les étudiants ajoutés sont persistés. Cependant, l’inverse ne fonctionne pas.

Nous allons résoudre ce problème au moment de la modification de l’entité Student. Le principe est le suivant :

  • Récupération de tous les événements de l’étudiant modifié,
  • Comparaison entre cette liste et la liste récupérée dans le formulaire,
  • Et enfin ajout et retrait des différences.

Dans EventRepository, on va créer une nouvelle requête pour récupérer tous les événements d’un étudiant. Par habitude, j’ai créé des alias de table au préalable pour éviter les erreurs sur les noms de champs identiques.

public function findAllForStudent(string $studentId)
    {
        return $this->createQueryBuilder(self::ALIAS)
            ->select(self::ALIAS)
            ->leftJoin(self::ALIAS . '.students' , StudentRepository::ALIAS )
            ->where(StudentRepository::ALIAS . '.id = :student')
            ->setParameter('student', $studentId)
            ->orderBy(self::ALIAS . '.name', 'ASC')
            ->getQuery()
            ->getResult();
    }

Pour effectuer la comparaison, j’ai créé une classe générique dans un dossier Helper

<?php

namespace App\Helper;

/**
 * @author Emmanuel SAUVAGE <emmanuel.sauvage@live.fr>
 * @version 1.0.0
 */
class ArrayDiff
{
    /**
     * @var array
     */
    private $oldData;

    /**
     * @var array
     */
    private $newData;

    public function __construct(array $oldData=[],array $newData=[])
    {
        $this->oldData=$oldData;
        $this->newData=$newData;
    }

    public function getDeleteDiff()
    {
        return array_udiff_assoc(
            $this->oldData,
            $this->newData,
            function($a, $b) {return $a === $b ? 0 : 1;}
        );
    }

    public function getInsertDiff()
    {
        return array_udiff_assoc(
            $this->newData,
            $this->oldData,
            function($a, $b) {return $a === $b ? 0 : 1;}
        );
    }
}

Et pour terminer, j’ai procédé à la comparaison dans la fonction edit du contrôleur Student

/**
* @Route("/{id}/edit", name="app_student_edit", methods={"GET", "POST"})
*/
public function edit(Request $request, Student $student, StudentRepository $studentRepository, EventRepository $eventRepository): Response
{
    $form = $this->createForm(StudentType::class, $student);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {

        $this->setRelation(
            $student,
            $eventRepository->findAllForStudent($student->getId()),
            $student->getEvents()
        );

        $studentRepository->add($student);
        return $this->redirectToRoute('app_student_index', [], Response::HTTP_SEE_OTHER);
    }

    return $this->renderForm('student/edit.html.twig', [
        'student' => $student,
        'form' => $form,
    ]);
}

private function setRelation(Student $student, $entitysOld, $entitysNew)
{
    $em = new ArrayDiff($entitysOld, $entitysNew->toArray());

    foreach ($em->getDeleteDiff() as $entity) {
        $entity->removeStudent($student);
    }

    foreach ($em->getInsertDiff() as $entity) {
        $entity->addStudent($student);
    }
}

Pour conclure

La mise en oeuvre de la persistance bi-directionnelle demande un peu de travail.

Dans cet exemple, je n’ai pas respecté mes habitudes de travail à savoir de mettre le minimum de code dans les contrôleurs. La bonne pratique serait d’extraire le code additionnel du contrôleur en utilisant par exemple un manager. Cependant le but était de mettre en œuvre la persistance bi-directionnelle sur les entités avec une relation ManyToMany.

Cliquez pour évaluer cet article !
[Total: 2 Moyenne : 3]

Une réflexion sur “Symfony et la persistance bi-directionnelle des relations ManyToMany

  • Bonjour, j’ai effectué les ajout de code afin de pouvoir modifier mes entité d’un coté comme de l’autre mais malheureusement j’ai ce message qui s’affiche Attempted to call an undefined method named « toArray » of class.

    j’ai voulu définir une fonction toArray() mais ceci ne fait rien =.

    pouvez vous me dire si il y a une solution a ce problème car je sèche.
    merci.

    Répondre

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *