Tous droits réservés

Relation ManyToMany - n..m

Dernière mise à jour :

Notre système de sondage est presque complet. Il ne reste plus qu’à gérer les participations des utilisateurs et notre modèle sera finalisé.

Sachant qu’un utilisateur peut participer à plusieurs sondages différents et qu’un sondage peut avoir plusieurs participations, nous avons là une relation n..m.

Ce genre de relations est, sans doute, le type de relation le plus complexe mais hélas assez courant.

Doctrine les gère avec l’annotation ManyToMany. Comme pour les autres types de relation, une relation ManyToMany peut être bidirectionnelle. La configuration étant semblable, nous ne l’aborderons pas.

Relation ManyToMany simple

Nous pouvons commencer par essayer de recenser tous les utilisateurs qui ont participé à un sondage.

Pour obtenir un schéma de données permettant de gérer cette contrainte, nous sommes obligés d’avoir une table de jointure.

Relation ManyToMany : Plusieurs utilisateurs , plusieurs sondages
Relation ManyToMany : Plusieurs utilisateurs , plusieurs sondages

La configuration de l’annotation ManyToMany est très simple. La seule question que nous devons nous poser est :

Depuis quelle entité devrons-nous faire référence à l’autre ?

Dans notre cas, nous allons faire un choix complétement arbitraire. Depuis un utilisateur, nous serons en mesure de voir tous les sondages auxquels il a participé. L’annotation ManyToMany sera donc configurée sur l’entité utilisateur.

<?php
# src/Entity/User.php

namespace Tuto\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

// ...
class User
{
    //...

    /**
    * @ORM\ManyToMany(targetEntity=Poll::class)
    */
    protected $polls;

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

    // ...
}

Il faut noter que si nous voulons, par la suite, accéder aux utilisateurs depuis un sondage, nous pourrons rendre la relation bidirectionnelle.

Générons le code SQL avec la commande Doctrine :

-- vendor/bin/doctrine orm:schema-tool:update --dump-sql

CREATE TABLE user_poll (user_id INT NOT NULL, poll_id INT NOT NULL, INDEX IDX_FE3DB68CA76ED395 (user_id), 
INDEX IDX_FE3DB68C3C947C0F (poll_id), PRIMARY KEY(user_id, poll_id)) DEFAULT CHARACTER SET utf8 
COLLATE utf8_unicode_ci ENGINE = InnoDB;
ALTER TABLE user_poll ADD CONSTRAINT FK_FE3DB68CA76ED395 FOREIGN KEY (user_id) 
REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE user_poll ADD CONSTRAINT FK_FE3DB68C3C947C0F FOREIGN KEY (poll_id) 
REFERENCES polls (id) ON DELETE CASCADE;

Le nom de la table de jointure est user_poll. Il n’est pas assez explicite et ne permet pas de voir de manière claire la nature de la relation. En plus, nous avons défini une convention au début de ce cours pour mettre tous les noms de table au pluriel.

Mais heureusement, nous pouvons le personnaliser en utilisant l’annotation JoinTable.

<?php
# src/Entity/User.php

namespace Tuto\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

// ...
class User
{
    // ...

    /**
    * @ORM\ManyToMany(targetEntity=Poll::class)
    * @ORM\JoinTable(name="participations")
    */
    protected $polls;

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

    // ...
}

Le nom de la table est maintenant correct. Mais nous n’allons pas encore la créer.

Relation ManyToMany avec attributs

Comment rajouter la date à laquelle l’utilisateur a participé au sondage ?

Dans un modèle purement SQL, cette information doit se retrouver dans la table participations. Mais avec l’ORM, nous avons juste les entités utilisateur et sondage. La date ne peut être lié à aucunes des deux.

En effet, si la date de participation est liée à l’utilisateur, nous ne pourrons conserver qu’une seule date de participation par utilisateur (celui du dernier sondage).

Et si cette date est liée au sondage, nous ne pourrons conserver que la date de participation d’un seul utilisateur (celui du dernier utilisateur ayant participé au sondage). Nous atteignons là une limite de la relation ManyToMany.

Mais le problème peut être résolu en utilisant une double relation ManyToOne. Considérons que le fait de participer à un sondage est matérialisé par une entité participation.

Ainsi, un utilisateur peut avoir plusieurs participations (en répondant à plusieurs sondages). Et une participation est créée par un seul utilisateur.

Relation entre Participation et Utilisateur
Relation entre Participation et Utilisateur

Donc entre une participation et un utilisateur, nous avons une relation ManyToOne. Mais cela ne s’arrête pas là !

Un sondage peut avoir plusieurs participations (plusieurs utilisateurs peuvent répondre à un même sondage). Une participation est liée à un sondage.

Relation entre Participation et Sondage
Relation entre Participation et Sondage

Nous avons donc aussi une relation ManyToOne entre une participation et un sondage.

Nous pouvons maintenant réécrire la relation ManyToMany. Il faudra d’abord créer une entité participation qui sera liée aux entités utilisateur et sondage.

<?php
# src/Entity/Participation.php

namespace Tuto\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity
* @ORM\Table(name="participations",
    uniqueConstraints={
        @ORM\UniqueConstraint(name="user_poll_unique", columns={"user_id", "poll_id"})
    }
  )
*/
class Participation
{
    /**
    * @ORM\Id
    * @ORM\GeneratedValue
    * @ORM\Column(type="integer")
    */
    protected $id;

    /**
    * @ORM\Column(type="datetime")
    */
    protected $date;

    /**
    * @ORM\ManyToOne(targetEntity=User::class, inversedBy="participations")
    */
    protected $user;

    /**
    * @ORM\ManyToOne(targetEntity=Poll::class)
    */
    protected $poll;

    public function __toString()
    {
        $format = "Participation (Id: %s, %s, %s)\n";
        return sprintf($format, $this->id, $this->user, $this->poll);
    }

   // ...
}

Il y a une petite subtilité dans la configuration de celle-ci. Nous avons rajouté une contrainte d’unicité sur les champs user et poll en utilisant l’annotation UniqueConstraint. Son utilisation est proche de celle de l’annotation Index.

Avec cette contrainte, nous interdisons à un utilisateur de répondre plusieurs fois à un même sondage.

Pour l’entité utilisateur, nous allons juste reconfigurer l’attribut polls.

<?php
# src/Entity/User.php

namespace Tuto\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

// ...
class User
{
    // ...

    /**
    * @ORM\OneToMany(targetEntity=Participation::class, mappedBy="user")
    */
    protected $participations;

    public function __construct()
    {
        $this->participations = new ArrayCollection();
    }
     
    // ...
}

Pour l’entité sondage, puisque la relation est unidirectionnelle, nous avons aucune modification à faire dessus.

En relançant la commande de mise à jour de la base de données, vous pouvez voir que le code SQL généré est exactement le même que celui dans la relation ManyToMany.

-- vendor/bin/doctrine orm:schema-tool:update --dump-sql 
CREATE TABLE participations (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, poll_id INT DEFAULT NULL, 
INDEX IDX_FDC6C6E8A76ED395 (user_id), INDEX IDX_FDC6C6E83C947C0F (poll_id),
 UNIQUE INDEX user_poll_unique (user_id, poll_id),
 PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
ALTER TABLE participations ADD CONSTRAINT FK_FDC6C6E8A76ED395 FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE participations ADD CONSTRAINT FK_FDC6C6E83C947C0F FOREIGN KEY (poll_id) REFERENCES polls (id);

Toute relation ManyToMany peut ainsi être décomposée en deux relations ManyToOne.

Décomposition de la relation ManyToMany
Décomposition de la relation ManyToMany

Revenons maintenant à notre problème initial : Comment rajouter la date à laquelle l’utilisateur a participé à un sondage ?

Avec notre nouveau modèle de données, la réponse est simple. Il suffit de rajouter la date de participation à l’entité participation.

<?php
# src/Entity/Participation.php

namespace Tuto\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity
* @ORM\Table(name="participations",
    uniqueConstraints={
        @ORM\UniqueConstraint(name="user_poll_unique", columns={"user_id", "poll_id"})
    }
  )
*/
class Participation
{
    /**
    * @ORM\Id
    * @ORM\GeneratedValue
    * @ORM\Column(type="integer")
    */
    protected $id;

    /**
    * @ORM\Column(type="datetime")
    */
    protected $date;

    /**
    * @ORM\ManyToOne(targetEntity=User::class, inversedBy="participations")
    */
    protected $user;

    /**
    * @ORM\ManyToOne(targetEntity=Poll::class)
    */
    protected $poll;

    public function __toString()
    {
        $format = "Participation (Id: %s, %s, %s)\n";
        return sprintf($format, $this->id, $this->user, $this->poll);
    }

    // ...
}

Nous pouvons maintenant mettre à jour la base de données.

-- vendor/bin/doctrine orm:schema-tool:update --dump-sql --force
CREATE TABLE participations (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, poll_id INT DEFAULT NULL,
 date DATETIME NOT NULL, INDEX IDX_FDC6C6E8A76ED395 (user_id), INDEX IDX_FDC6C6E83C947C0F (poll_id),
 UNIQUE INDEX user_poll_unique (user_id, poll_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 
COLLATE utf8_unicode_ci ENGINE = InnoDB;
ALTER TABLE participations ADD CONSTRAINT FK_FDC6C6E8A76ED395 FOREIGN KEY (user_id) REFERENCES users (id);
ALTER TABLE participations ADD CONSTRAINT FK_FDC6C6E83C947C0F FOREIGN KEY (poll_id) REFERENCES polls (id);

Création des participations

Nous allons tester notre configuration en créant une participation au sondage « Doctrine 2, ça vous dit ? ».

<?php
# create-participation.php

$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);

use Tuto\Entity\Participation;
use Tuto\Entity\Poll;
use Tuto\Entity\User;

$participation = new Participation();

$participation->setDate(new \Datetime("2017-03-03T09:00:00Z"));

$pollRepo = $entityManager->getRepository(Poll::class);
$poll = $pollRepo->find(1);

$userRepo = $entityManager->getRepository(User::class);
$user = $userRepo->find(4);

$participation->setUser($user);
$participation->setPoll($poll);

$entityManager->persist($participation);
$entityManager->flush();

echo $participation;
Participation (Id: 1, User (id: 4, firstname: First 3, lastname: LAST 3, role: user, address: )
, Poll (id: 1, title: Doctrine 2, ça vous dit ?, created: 2017-03-03T08:00:00+0000)
)

Grâce à la clé primaire que nous avons définie, un utilisateur ne pourra pas participer deux fois au même sondage. Si vous ré-exécuter l’extrait de code, vous aurez une erreur fatale (Fatal error: Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicata du champ '4–1' pour la clef 'user_poll_unique’).


Chacune des annotations que nous venons de voir comporte un ensemble de paramètres qui permettent de les personnaliser. N’hésitez donc pas à consulter la documentation officielle sur les associations pour approfondir le sujet.

La référence complète est disponible sur le site de Doctrine.