Regardons plus en détails le modèle de notre sondage. Une question doit avoir plusieurs réponses au choix et chaque réponse ne peut être associée qu’à une seule question.
Dans cette partie, nous allons voir comment matérialiser ce genre de relations courantes (1..n) avec Doctrine.
Relation ManyToOne
Nous devons avoir en tête le schéma que nous désirons avant de pouvoir le réaliser avec Doctrine.
Notre modèle de données pour intégrer les questions et leurs réponses ressemblerait à :
Un tel résultat peut être obtenu avec Doctrine en utilisant l’annotation ManyToOne
. Prenons une réponse ayant comme attribut un libellé et la question à laquelle elle est associée. Une question aura, quant à elle, un libellé et une liste de réponses possibles.
Mais où placer l’annotation ManyToOne
, dans l’entité question ou dans l’entité réponse ?
Cette annotation est souvent source de problèmes et d’incompréhensions. Il existe d’ailleurs plusieurs astuces pour l’utiliser à bon escient.
Reprenons notre exemple :
- Une réponse est liée à une seule (one) question ;
- Une question peut avoir plusieurs (many) réponses.
Dans une relation ManyToOne
, le many qualifie l’entité qui doit contenir l’annotation. Ici le many qualifie les réponses. Donc, pour notre cas, l’annotation doit être dans l’entité réponse.
Nos deux entités doivent donc être configurées comme suit :
- la question :
<?php
# src/Entity/Question.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="questions")
*/
class Question
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @ORM\Column(type="text")
*/
protected $wording;
public function __toString()
{
$format = "Question (id: %s, wording: %s)\n";
return sprintf($format, $this->id, $this->wording);
}
// getters et setters
}
- et la réponse :
<?php
# src/Entity/Answer.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="answers")
*/
class Answer
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @ORM\Column(type="string")
*/
protected $wording;
/**
* @ORM\ManyToOne(targetEntity=Question::class)
*/
protected $question;
public function __toString()
{
$format = "Answer (id: %s, wording: %s)\n";
return sprintf($format, $this->id, $this->wording);
}
// getters et setters
}
Il suffit de mettre à jour la base de données à l’aide de l’invite de commande de Doctrine.
-- vendor/bin/doctrine orm:schema-tool:update --dump-sql --force
CREATE TABLE answers (id INT AUTO_INCREMENT NOT NULL, question_id INT DEFAULT NULL, wording VARCHAR(255)
NOT NULL, INDEX IDX_50D0C6061E27F6BF (question_id), PRIMARY KEY(id))
DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
CREATE TABLE questions (id INT AUTO_INCREMENT NOT NULL, wording LONGTEXT NOT NULL, PRIMARY KEY(id))
DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
ALTER TABLE answers ADD CONSTRAINT FK_50D0C6061E27F6BF FOREIGN KEY (question_id) REFERENCES questions (id);
Relation OneToMany
Dans le cas des questions et réponses, il est légitime de vouloir accéder aux réponses depuis l’entité question (pour afficher rapidement les réponses associées à une question par exemple).
Comme pour la relation OneToOne
, nous pouvons rendre une relation ManyToOne
bidirectionnelle en utilisant l’annotation miroir OneToMany
. La configuration est assez proche de celle de l’annotation OneToOne
. Nous pouvons même activer les opérations de cascade pour créer une question et toutes les réponses associées plus facilement.
Ainsi, à l’image de toutes les relations bidirectionnelles, chacune des deux entités doit maintenant être configurée pour faire référence à l’autre.
La configuration finale est donc :
<?php
# src/Entity/Answer.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="answers")
*/
class Answer
{
// ...
/**
* @ORM\ManyToOne(targetEntity=Question::class, inversedBy="answers")
*/
protected $question;
// ...
}
<?php
# src/Entity/Question.php
namespace Tuto\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="questions")
*/
class Question
{
// ...
/**
* @ORM\OneToMany(targetEntity=Answer::class, cascade={"persist", "remove"}, mappedBy="question")
*/
protected $answers;
// ...
}
Testons le tout en créant une question et plusieurs réponses associées.
Pour créer une question, le code est simple.
<?php
# create-question.php
$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);
use Tuto\Entity\Question;
use Tuto\Entity\Answer;
$question = new Question();
$question->setWording("Doctrine 2 est-il un bon ORM ?");
$entityManager->persist($question);
$entityManager->flush();
echo $question;
Mais comment relier une réponse à une question ?
L’attribut answers
de la classe Question
représente une liste de réponses. Avec Doctrine, nous pouvons représenter une collection grâce à la classe ArrayCollection
.
Cette classe permettra à Doctrine de gérer convenablement tous les changements qui pourront subvenir sur la collection.
L’API de la classe ArrayCollection
est pratique pour manipuler des listes (ajout, suppression, recherche d’un élément).
Il est d’ailleurs possible d’utiliser le package doctrine/collections dans des projets pour profiter des fonctionnalités des collections sans installer l’ORM Doctrine.
Nous allons donc modifier l’entité question pour rajouter ces modifications.
<?php
# src/Entity/Question.php
namespace Tuto\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="questions")
*/
class Question
{
// ...
/**
* @ORM\OneToMany(targetEntity=Answer::class, cascade={"persist", "remove"}, mappedBy="question")
*/
protected $answers;
public function __construct()
{
$this->answers = new ArrayCollection();
}
// ...
public function getAnswers()
{
return $this->answers;
}
public function addAnswer(Answer $answer)
{
$this->answers->add($answer);
$answer->setQuestion($this);
}
}
Dans la méthode addAnswer
, nous avons rajouté une petite logique qui permet de maintenir l’application dans un état cohérent. Lorsqu’une réponse est associée à une question, cette question est elle aussi automatiquement liée à la réponse.
Cette liaison est obligatoire pour le bon fonctionnement de la cascade d’opération de Doctrine (vous pourrez consulter la partie annexe de ce cours pour une explication en détail - Owning side - Inverse side). Pour chaque relation faisant intervenir une collection, il faudra y porter une attention particulière.
Nous pouvons maintenant compléter la création de la question.
<?php
# create-question.php
$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);
use Tuto\Entity\Question;
use Tuto\Entity\Answer;
$question = new Question();
$question->setWording("Doctrine 2 est-il un bon ORM ?");
$yes = new Answer();
$yes->setWording("Oui, bien sûr !");
$question->addAnswer($yes);
$no = new Answer();
$no->setWording("Non, peut mieux faire.");
$question->addAnswer($no);
$entityManager->persist($question);
$entityManager->flush();
echo $question;
Grâce à la cascade des opérations de sauvegarde, nous ne sommes pas obligés de persister les réponses. Doctrine regarde l’état de la collection et fait le nécessaire pour que les réponses soient sauvegardées correctement.
Pratiquons : Création d'une entité sondage
Énoncé
Pour pratiquer ce que nous venons de voir, nous allons créer une entité sondage avec deux attributs :
- son titre : une chaîne de caractères qui permet de décrire le sondage ;
- sa date de création : une date PHP permettant de savoir la date de création du sondage.
Le sondage est constitué d’une liste de questions. La relation entre les deux entités doit être bidirectionnelle mais aucune opération de cascade ne doit être définie.
Proposition de solution
Nous allons créer une entité nommé Poll
pour représenter le sondage et éditer l’entité question pour le lier au sondage.
Si nous réécrivons notre exemple :
- Une question est liée à un seul (one) sondage ;
- Un sondage peut avoir plusieurs (many) questions.
Dans une relation
ManyToOne
, le many qualifie l’entité qui doit contenir l’annotation.
Donc, pour notre cas, l’annotation doit être dans l’entité question.
La configuration des entités est donc :
<?php
# src/Entity/Poll.php
namespace Tuto\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="polls")
*/
class Poll
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @ORM\Column(type="string")
*/
protected $title;
/**
* @ORM\Column(type="datetime")
*/
protected $created;
/**
* @ORM\OneToMany(targetEntity=Question::class, mappedBy="poll")
*/
protected $questions;
public function __construct()
{
$this->questions = new ArrayCollection();
}
public function __toString()
{
$format = "Poll (id: %s, title: %s, created: %s)\n";
return sprintf($format, $this->id, $this->title, $this->created->format(\Datetime::ISO8601));
}
// ...
public function addQuestion(Question $question)
{
// Toujours maintenir la relation cohérente
$this->questions->add($question);
$question->setPoll($this);
}
}
<?php
# src/Entity/Question.php
namespace Tuto\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="questions")
*/
class Question
{
// ...
/**
* @ORM\ManyToOne(targetEntity=Poll::class, inversedBy="questions")
*/
protected $poll;
// ...
}
Mettons à jour la base de données :
-- vendor/bin/doctrine orm:schema-tool:update --dump-sql --force
CREATE TABLE polls (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, created DATETIME NOT NULL,
PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
ALTER TABLE questions ADD poll_id INT DEFAULT NULL;
ALTER TABLE questions ADD CONSTRAINT FK_8ADC54D53C947C0F FOREIGN KEY (poll_id) REFERENCES polls (id);
CREATE INDEX IDX_8ADC54D53C947C0F ON questions (poll_id);
Nous pouvons maintenant tester la création d’un sondage avec quelques questions. La seule nouveauté ici sera l’utilisation des dates PHP.
En effet, Doctrine transforme automatiquement les dates pour nous. Le seul élément à prendre en compte est le fuseau horaire. Vu que MySQL ne stocke pas le fuseau horaire dans ses dates, nous devons définir dans PHP un fuseau horaire identique pour toutes les applications qui pourraient d’utiliser les mêmes données.
Nous pouvons utiliser la fonction PHP date_default_timezone_set ou le paramètre d’initialisation date.timezone.
<?php
# create-poll.php
$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);
use Tuto\Entity\Poll;
use Tuto\Entity\Question;
use Tuto\Entity\Answer;
$poll = new Poll();
$poll->setTitle("Doctrine 2, ça vous dit ?");
$poll->setCreated(new \Datetime("2017-03-03T08:00:00Z"));
# Question 1
$questionOne = new Question();
$questionOne->setWording("Doctrine 2 est-il un bon ORM ?");
$yes = new Answer();
$yes->setWording("Oui, bien sûr !");
$questionOne->addAnswer($yes);
$no = new Answer();
$no->setWording("Non, peut mieux faire.");
$questionOne->addAnswer($no);
# Ajout de la question au sondage
$poll->addQuestion($questionOne);
# Question 2
$questionTwo = new Question();
$questionTwo->setWording("Doctrine 2 est-il facile d'utilisation ?");
$yesDoc = new Answer();
$yesDoc->setWording("Oui, il y a une bonne documentation !");
$questionTwo->addAnswer($yesDoc);
$yesTuto = new Answer();
$yesTuto->setWording("Oui, il y a de bons tutoriels !");
$questionTwo->addAnswer($yesTuto);
$no = new Answer();
$no->setWording("Non.");
$questionTwo->addAnswer($no);
# Ajout de la question au sondage
$poll->addQuestion($questionTwo);
$entityManager->persist($questionOne);
$entityManager->persist($questionTwo);
$entityManager->persist($poll);
$entityManager->flush();
echo $poll;
Une fois que le sondage est créé, nous pouvons le récupérer et l’afficher. Grâce aux relations que nous avons définies, Doctrine peut chercher toutes les informations dont nous avons besoin en se basant juste sur le sondage. Voyez donc par vous-même :
<?php
# get-poll.php
$entityManager = require_once join(DIRECTORY_SEPARATOR, [__DIR__, 'bootstrap.php']);
use Tuto\Entity\Poll;
$pollRepo = $entityManager->getRepository(Poll::class);
$poll = $pollRepo->find(1);
echo $poll;
foreach ($poll->getQuestions() as $question) {
echo "- ", $question;
foreach ($question->getAnswers() as $answer) {
echo "-- ", $answer;
}
}
Poll (id: 1, title: Doctrine 2, ça vous dit ?, created: 2017-03-03T08:00:00+0000)
- Question (id: 2, wording: Doctrine 2 est-il un bon ORM ?)
-- Answer (id: 3, wording: Oui, bien sûr !)
-- Answer (id: 4, wording: Non, peut mieux faire.)
- Question (id: 3, wording: Doctrine 2 est-il facile d'utilisation ?)
-- Answer (id: 5, wording: Oui, il y a une bonne documentation !)
-- Answer (id: 6, wording: Oui, il y a de bons tutoriels !)
-- Answer (id: 7, wording: Non.)
Le principe des relations ManyToOne
reste semblable à une relation OneToOne
. Doctrine utilise la notion de collection afin de bien gérer nos entités.
Nous pouvons donc exploiter l'API de cette classe pour consulter, ajouter, modifier ou supprimer une entité appartenant à une relation 1..n très facilement.
Notre application reste ainsi cohérente et facile d’utilisation.
Dans les faits, une annotation OneToMany
ne peut pas exister toute seule. Si la relation 1..n est unidirectionnelle, il faut obligatoirement avoir l’annotation ManyToOne
. C’est une contrainte que Doctrine nous impose (Cf Annexe).