• 30 heures
  • Facile

Ce cours est visible gratuitement en ligne.

Ce cours est en vidéo.

Ce cours existe en livre papier.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

Vous pouvez être accompagné et mentoré par un professeur particulier par visioconférence sur ce cours.

J'ai tout compris !

Les relations entre entités avec Doctrine2

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Maintenant que vous savez créer et manipuler une entité simple, on va monter en puissance.

L'objectif de ce chapitre est de construire un ensemble d'entités en relation les unes avec les autres. Ces relations permettent de disposer d'un ensemble cohérent, qui se manipule simplement et en toute sécurité pour votre base de données.

 

Présentation

Présentation

Vous savez déjà stocker vos entités indépendamment les unes des autres, c'est très bien. Simplement, on est rapidement limités ! L'objectif de ce chapitre est de vous apprendre à établir des relations entre les entités.

Rappelez-vous, au début de la partie sur Doctrine2, je vous avais promis des choses comme $utilisateur->getCommentaires(). Eh bien, c'est cela que nous allons faire ici !

Les différents types de relations

Il y a plusieurs façons de lier des entités entre elles. En effet, il n'est pas pareil de lier une multitude de commentaires à un seul article et de lier un membre à un seul groupe. Il existe donc plusieurs types de relations, pour répondre à plusieurs besoins concrets. Ce sont les relationsOneToOne,OneToManyetManyToMany. On les étudie juste après ces quelques notions de base à avoir.

Notions techniques d'ORM à savoir

Avant de voir en détail les relations, il faut comprendre comment elles fonctionnent. N'ayez pas peur, il y a juste deux notions à savoir avant d'attaquer.

Notion de propriétaire et d'inverse

La notion de propriétaire et d'inverse est abstraite mais importante à comprendre. Dans une relation entre deux entités, il y a toujours une entité dite propriétaire, et une dite inverse. Pour comprendre cette notion, il faut revenir à la vieille époque, lorsque l'on faisait nos bases de données à la main. L'entité propriétaire est celle qui contient la référence à l'autre entité. Attention, cette notion — à avoir en tête lors de la création des entités — n'est pas liée à votre logique métier, elle est purement technique.

Prenons un exemple simple, les commentaires de nos annonces. En SQL pur, vous disposez de la tablecomment et de la tableadvert. Pour créer une relation entre ces deux tables, vous allez mettre naturellement une colonneadvert_iddans la tablecomment. La tablecomment est donc propriétaire de la relation, car c'est elle qui contient la colonne de liaisonadvert_id. Assez simple au final !

Notion d'unidirectionnalité et de bidirectionnalité

Cette notion est également simple à comprendre : une relation peut être à sens unique ou à double sens. On ne va traiter dans ce chapitre que les relations à sens unique, dites unidirectionnelles. Cela signifie que vous pourrez faire$entiteProprietaire->getEntiteInverse()(dans notre exemple$comment->getAdvert()), mais vous ne pourrez pas faire$entiteInverse->getEntiteProprietaire()(pour nous,$advert->getComments()). Attention, cela ne nous empêchera pas de récupérer les commentaires d'une annonce, on utilisera juste une autre méthode, via le repository.

Cette limitation nous permet de simplifier la façon de définir les relations. Pour bien travailler avec, il suffit juste de se rappeler qu'on ne peut pas faire$entiteInverse->getEntiteProprietaire().

Pour des cas spécifiques, ou des préférences dans votre code, cette limitation peut être contournée en utilisant les relations à double sens, dites bidirectionnelles. Je les expliquerai rapidement à la fin de ce chapitre.

Rien n'est magique

Non, rien n'est magique. Je dois vous avertir qu'un$advert->getComments()est vraiment sympa, mais qu'il déclenche bien sûr une requête SQL ! Lorsqu'on récupère une entité (notre$advert par exemple), Doctrine ne récupère pas toutes les entités qui lui sont liées (les commentaires dans l'exemple), et heureusement ! S'il le faisait, cela serait extrêmement lourd. Imaginez qu'on veuille juste récupérer une annonce pour avoir son titre, et Doctrine nous récupère la liste des 54 commentaires, qui en plus sont liés à leurs 54 auteurs respectifs, etc. !

Doctrine utilise ce qu'on appelle le Lazy Loading, « chargement fainéant » en français. C'est-à-dire qu'il ne va charger les entités à l'autre bout de la relation que si vous voulez accéder à ces entités. C'est donc pile au moment où vous faites$advert->getComments()que Doctrine va charger les commentaires (avec une nouvelle requête SQL donc) puis va vous les transmettre.

Heureusement pour nous, il est possible d'éviter cela ! Parce que cette syntaxe est vraiment pratique, il serait dommage de s'en priver pour cause de requêtes SQL trop nombreuses. Il faudra simplement utiliser nos propres méthodes pour charger les entités, dans lesquelles nous ferons des jointures toutes simples. L'idée est de dire à Doctrine : « Charge l'entitéAdvert, mais également tous ses commentaires ». Avoir nos propres méthodes pour cela permet de ne les exécuter que si nous voulons vraiment avoir les commentaires en plus de l'annonce. En somme, on se garde le choix de charger ou non la relation.

Mais nous verrons tout cela dans le prochain chapitre sur les repositories. Pour l'instant, revenons à nos relations !

Relation One-To-One

Présentation

La relation One-To-One, ou 1..1, est assez classique. Elle correspond, comme son nom l'indique, à une relation unique entre deux objets.

Pour illustrer cette relation dans le cadre de notre plateforme d'annonces, nous allons créer une entitéImage. Imaginons qu'on offre la possibilité de lier une image à une annonce, une sorte d'icône pour illustrer un peu l'annonce. Si à chaque annonce on ne peut afficher qu'une seule image, et que chaque image ne peut être liée qu'à une seule annonce, alors on est bien dans le cadre d'une relation One-To-One. La figure suivante schématise tout cela.

Une annonce est liée à une seule image, une image est liée à une seule annonce
Une annonce est liée à une seule image, une image est liée à une seule annonce

Tout d'abord, histoire qu'on parle bien de la même chose, créez cette entitéImageavec au moins les attributsurletaltpour qu'on puisse l'afficher correctement. Voici la mienne :

<?php
// src/OC/PlatformBundle/Entity/Image

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\ImageRepository")
 */
class Image
{
  /**
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

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

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

Définition de la relation dans les entités

Annotation

Pour établir une relation One-To-One entre deux entitésAdvert etImage, la syntaxe est la suivante :

Entité propriétaire,Advert :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

/**
 * @ORM\Entity
 */
class Advert
{
  /**
   * @ORM\OneToOne(targetEntity="OC\PlatformBundle\Entity\Image", cascade={"persist"})
   */
  private $image;

  // …
}

Entité inverse,Image:

<?php
// src/OC/PlatformBundle/Entity/Image

/**
 * @ORM\Entity
 */
class Image
{
  // Nul besoin d'ajouter une propriété ici

  // …
}

La définition de la relation est plutôt simple, mais détaillons-la bien.

Tout d'abord, j'ai choisi de définir l'entitéAdvert comme entité propriétaire de la relation, car unAdvert « possède » uneImage. On aura donc plus tendance à récupérer l'image à partir de l'annonce que l'inverse. Cela permet également de rendre indépendante l'entitéImage: elle pourra être utilisée par d'autres entités queAdvert, de façon totalement invisible pour elle.

Ensuite, vous voyez que seule l'entité propriétaire a été modifiée, iciAdvert. C'est parce qu'on a une relation unidirectionnelle, rappelez-vous, on peut donc faire$advert->getImage(), mais pas$image->getAdvert(). Dans une relation unidirectionnelle, l'entité inverse, iciImage, ne sait en fait même pas qu'elle est liée à une autre entité, ce n'est pas son rôle.

Enfin, concernant l'annotation en elle-même :

@ORM\OneToOne(targetEntity="OC\PlatformBundle\Entity\Image", cascade={"persist"})

Il y a plusieurs choses à savoir sur cette annotation :

  • Elle est incompatible avec l'annotation@ORM\Columnqu'on a vue dans un chapitre précédent. En effet, l'annotationColumndéfinit une valeur (un nombre, une chaine de caractères, etc.), alors queOneToOnedéfinit une relation vers une autre entité. Lorsque vous utilisez l'un, vous ne pouvez pas utiliser l'autre sur le même attribut.

  • Elle possède au moins l'optiontargetEntity, qui vaut simplement le namespace complet vers l'entité liée.

  • Elle possède d'autres options facultatives, dont l'optioncascadedont on parle un peu plus loin.

Rendre une relation non-facultative

Par défaut, une relation est facultative, c'est-à-dire que vous pouvez avoir unAdvert qui n'a pas d'Imageliée. C'est le comportement que nous voulons pour l'exemple : on se donne le droit d'ajouter une annonce sans forcément trouver une image d'illustration. Si vous souhaitez forcer la relation, il faut ajouter l'annotationJoinColumnet définir son optionnullableàfalse, comme ceci :

/**
  * @ORM\OneToOne(targetEntity="OC\PlatformBundle\Entity\Image", cascade={"persist"})
  * @ORM\JoinColumn(nullable=false)
  */
private $image;
Les opérations de cascade

Parlons maintenant de l'optioncascadeque l'on a vu un peu plus haut. Cette option permet de « cascader » les opérations que l'on ferait sur l'entitéAdvert à l'entitéImageliée par la relation.

Pour prendre l'exemple le plus simple, imaginez que vous supprimiez une entitéAdvert via un $em->remove($advert). Si vous ne précisez rien, Doctrine va supprimer l'Advert mais garder l'entitéImageliée. Or ce n'est pas forcément ce que vous voulez ! Si vos images ne sont liées qu'à des annonces, alors la suppression de l'annonce doit entraîner la suppression de l'image, sinon vous aurez desImagesorphelines dans votre base de données. C'est le but decascade. Attention, si vos images sont liées à des annonces mais aussi à d'autres entités, alors vous ne voulez pas forcément supprimer directement l'image d'une annonce, car elle pourrait être liée à une autre entité.

On peut cascader des opérations de suppression, mais également de persistance. En effet, on a vu qu'il fallait persister une entité avant d'exécuter leflush(), afin de dire à Doctrine qu'il doit enregistrer l'entité en base de données. Cependant, dans le cas d'entités liées, si on fait un$em->persist($advert), qu'est-ce que Doctrine doit faire pour l'entitéImagecontenue dans l'entitéAdvert ? Il ne le sait pas et c'est pourquoi il faut le lui dire : soit en faisant manuellement unpersist()sur l'annonce et l'image, soit en définissant dans l'annotation de la relation qu'unpersist()surAdvert doit se « propager » sur l'Imageliée.

C'est ce que nous avons fait dans l'annotation : on a défini lecascadesur l'opérationpersist(), mais pas sur l'opérationremove()(car on se réserve la possibilité d'utiliser les images pour autre chose que des annonces).

Getter et setter

D'abord, n'oubliez pas de définir un getter et un setter dans l'entité propriétaire, iciAdvert. Vous pouvez utiliser la commandephp app/console doctrine:generate:entities OCPlatformBundle:Advert, ou alors prendre ce code :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\AdvertRepository")
 */
class Advert
{
  /**
   * @ORM\OneToOne(targetEntity="OC\PlatformBundle\Entity\Image", cascade={"persist"})
   */
  private $image;

  // Vos autres attributs…
    
  public function setImage(Image $image = null)
  {
    $this->image = $image;
  }

  public function getImage()
  {
    return $this->image;
  }

  // Vos autres getters/setters…
}

Vous voyez qu'on a forcé le type de l'argument pour le settersetImage(): cela permet de déclencher une erreur si vous essayez de passer un autre objet queImageà la méthode. Très utile pour éviter de chercher des heures l'origine d'un problème parce que vous avez passé un mauvais argument. Notez également le «= null» qui permet d'accepter les valeursnull: rappelez-vous, la relation est facultative !

Prenez bien conscience d'une chose également : le gettergetImage()retourne une instance de la classeImagedirectement. Lorsque vous avez une annonce, disons$advert, et que vous voulez récupérer l'URL de l'Imageassociée, il faut donc faire :

<?php
$image = $advert->getImage();
$url = $image->getUrl();

// Ou bien sûr en une seule ligne :
$url = $advert->getImage()->getUrl();

Exemple d'utilisation

Pour utiliser cette relation, c'est très simple. Voici un exemple pour ajouter une nouvelle annonceAdvert et sonImagedepuis un contrôleur. Modifions l'actionaddAction(), qui était déjà bien complète :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

// N'oubliez pas ces use
use OC\PlatformBundle\Entity\Advert;
use OC\PlatformBundle\Entity\Image;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class AdvertController extends Controller
{
  public function addAction(Request $request)
  {
    // Création de l'entité Advert
    $advert = new Advert();
    $advert->setTitle('Recherche développeur Symfony2.');
    $advert->setAuthor('Alexandre');
    $advert->setContent("Nous recherchons un développeur Symfony2 débutant sur Lyon. Blabla…");

    // Création de l'entité Image
    $image = new Image();
    $image->setUrl('http://sdz-upload.s3.amazonaws.com/prod/upload/job-de-reve.jpg');
    $image->setAlt('Job de rêve');

    // On lie l'image à l'annonce
    $advert->setImage($image);

    // On récupère l'EntityManager
    $em = $this->getDoctrine()->getManager();

    // Étape 1 : On « persiste » l'entité
    $em->persist($advert);

    // Étape 1 bis : si on n'avait pas défini le cascade={"persist"},
    // on devrait persister à la main l'entité $image
    // $em->persist($image);

    // Étape 2 : On déclenche l'enregistrement
    $em->flush();

    // … reste de la méthode
    }

Si vous exécutez cette page, voici les requêtes SQL générées par Doctrine, que vous pouvez voir dans le Profiler :

Deux requêtes sont générées : l'ajout de l'image et l'ajout de l'annonce
Deux requêtes sont générées : l'ajout de l'image et l'ajout de l'annonce

Je vous laisse adapter la vue pour afficher l'image, si elle est présente, lors de l'affichage d'une annonce. Voici un exemple de ce que vous pouvez ajouter :

{# src/OC/PlatformBundle/Resources/view/Advert/view.html.twig #}

{# On vérifie qu'une image soit bien associée à l'annonce #}
{% if advert.image is not null %}
  <img src="{{ advert.image.url }}" alt="{{ advert.image.alt }}">
{% endif %}

Le résultat est visible sur la figure suivante.

L'image est affichée sur l'annonce correspondante
L'image est affichée sur l'annonce correspondante

Et voici un autre exemple, qui modifierait l'Imaged'une annonce déjà existante. Ici je vais prendre une méthode de contrôleur arbitraire, mais vous savez tout ce qu'il faut pour l'implémenter réellement :

<?php
// Dans un contrôleur, celui que vous voulez

public function editImageAction($advertId)
{
  $em = $this->getDoctrine()->getManager();

  // On récupère l'annonce
  $advert = $em->getRepository('OCPlatformBundle:Advert')->find($advertId);

  // On modifie l'URL de l'image par exemple
  $advert->getImage()->setUrl('test.png');

  // On n'a pas besoin de persister l'annonce ni l'image.
  // Rappelez-vous, ces entités sont automatiquement persistées car
  // on les a récupérées depuis Doctrine lui-même
  
  // On déclenche la modification
  $em->flush();

  return new Response('OK');
}

Le code parle de lui-même : gérer une relation est vraiment aisé avec Doctrine !

Relation Many-To-One

Présentation

La relation Many-To-One, ou n..1, est assez classique également. Elle correspond, comme son nom l'indique, à une relation qui permet à une entité A d'avoir une relation avec plusieurs entités B.

Pour illustrer cette relation dans le cadre de notre plateforme d'annonce, nous allons créer une entitéApplication qui représente la candidature d'une personne intéressée par l'annonce. L'idée est de pouvoir ajouter plusieurs candidatures à une annonce, et que chaque candidature ne soit liée qu'à une seule annonce. Nous avons ainsi plusieurs candidatures (Many) à lier (To) à une seule annonce (One). La figure suivante schématise tout cela.

Une annonce peut contenir plusieurs candidatures, alors qu'une candidature n'appartient qu'à une seule annonce
Une annonce peut contenir plusieurs candidatures, alors qu'une candidature n'appartient qu'à une seule annonce

Comme précédemment, pour être sûrs qu'on parle bien de la même chose, créez cette entité Application avec au moins les attributsauthor,content etdate. Voici la mienne, j'ai ajouté un constructeur pour définir une date par défaut, comme on l'a déjà fait pour l'entitéAdvert :

<?php
// src/OC/PlatformBundle/Entity/Application.php

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\ApplicationRepository")
 */
class Application
{
  /**
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

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

  /**
   * @ORM\Column(name="content", type="text")
   */
  private $content;

  /**
   * @ORM\Column(name="date", type="datetime")
   */
  private $date;
  
  public function __construct()
  {
    $this->date = new \Datetime();
  }

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

  public function setAuthor($author)
  {
    $this->author = $author;

    return $this;
  }

  public function getAuthor()
  {
    return $this->author;
  }

  public function setContent($content)
  {
    $this->content = $content;

    return $this;
  }

  public function getContent()
  {
    return $this->content;
  }

  public function setDate($date)
  {
    $this->date = $date;

    return $this;
  }

  public function getDate()
  {
    return $this->date;
  }
}

Définition de la relation dans les entités

Annotation

Pour établir cette relation dans votre entité, la syntaxe est la suivante :

Entité propriétaire,Application :

<?php
// src/OC/PlatformBundle/Entity/Application.php

/**
 * @ORM\Entity
 */
class Application
{
  /**
   * @ORM\ManyToOne(targetEntity="OC\PlatformBundle\Entity\Advert")
   * @ORM\JoinColumn(nullable=false)
   */
  private $advert;

  // …
}

Entité inverse,Advert :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

/**
 * @ORM\Entity
 */
class Advert
{
  // Nul besoin de rajouter de propriété, ici

  // …
}

L'annotation à utiliser est tout simplementManyToOne.

Première remarque : l'entité propriétaire pour cette relation estApplication, et nonAdvert. Pourquoi ? Parce que, rappelez-vous, le propriétaire est celui qui contient la colonne référence. Ici, on aura bien une colonneadvert_iddans la tableapplication. En fait, de façon systématique, c'est le côté Many d'une relation Many-To-One qui est le propriétaire, vous n'avez pas le choix. Ici, on a plusieurs candidatures pour une seule annonce, le Many correspond aux candidatures (application en anglais), donc l'entitéApplication est la propriétaire.

Deuxième remarque : j'ai volontairement ajouté l'annotationJoinColumnavec son attributnullableàfalse, pour interdire la création d'une candidature sans annonce. En effet, dans notre cas, une candidature qui n'est rattaché à aucune annonce n'a pas de sens. Après, attention, il se peut très bien que dans votre cas vous deviez laisser la possibilité au côté Many de la relation d'exister sans forcément être attaché à un côté One.

Getter et setter

Ajoutons maintenant le getter et le setter correspondants dans l'entité propriétaire. Comme tout à l'heure, vous pouvez utiliser la méthodephp app/console doctrine:generate:entities OCPlatformBundle:Application, ou alors mettez ceux-là :

<?php
// src/OC/PlatformBundle/Entity/Application.php

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\ApplicationRepository")
 */
class Application
{
  /**
   * @ORM\ManyToOne(targetEntity="OC\PlatformBundle\Entity\Advert")
   * @ORM\JoinColumn(nullable=false)
   */
  private $advert;

  // … reste des attributs

  public function setAdvert(Advert $advert)
  {
    $this->advert = $advert;

    return $this;
  }

  public function getAdvert()
  {
    return $this->advert;
  }

  // … reste des getters et setters
}

Exemple d'utilisation

La méthode pour gérer une relation Many-To-One n'est pas très différente de celle pour une relation One-To-One, voyez par vous-mêmes dans ces exemples.

Tout d'abord, pour ajouter un nouvelAdvert et sesApplication, modifions la méthodeaddAction()de notre contrôleur :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

use OC\PlatformBundle\Entity\Advert;
use OC\PlatformBundle\Entity\Application;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class AdvertController extends Controller
{
  public function addAction(Request $request)
  {
    // Création de l'entité Advert
    $advert = new Advert();
    $advert->setTitle('Recherche développeur Symfony2.');
    $advert->setAuthor('Alexandre');
    $advert->setContent("Nous recherchons un développeur Symfony2 débutant sur Lyon. Blabla…");

    // Création d'une première candidature
    $application1 = new Application();
    $application1->setAuthor('Marine');
    $application1->setContent("J'ai toutes les qualités requises.");

    // Création d'une deuxième candidature par exemple
    $application2 = new Application();
    $application2->setAuthor('Pierre');
    $application2->setContent("Je suis très motivé.");

    // On lie les candidatures à l'annonce
    $application1->setAdvert($advert);
    $application2->setAdvert($advert);

    // On récupère l'EntityManager
    $em = $this->getDoctrine()->getManager();

    // Étape 1 : On « persiste » l'entité
    $em->persist($advert);

    // Étape 1 bis : pour cette relation pas de cascade lorsqu'on persiste Advert, car la relation est
    // définie dans l'entité Application et non Advert. On doit donc tout persister à la main ici.
    $em->persist($application1);
    $em->persist($application2);

    // Étape 2 : On « flush » tout ce qui a été persisté avant
    $em->flush();

    // … reste de la méthode
  }
}

Pour information, voici comment on pourrait modifier l'actionviewAction()du contrôleur pour passer non seulement l'annonce à la vue, mais également toutes ses candidatures :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

  public function viewAction($id)
  {
    $em = $this->getDoctrine()->getManager();

    // On récupère l'annonce $id
    $advert = $em
      ->getRepository('OCPlatformBundle:Advert')
      ->find($id)
    ;

    if (null === $advert) {
      throw new NotFoundHttpException("L'annonce d'id ".$id." n'existe pas.");
    }

    // On récupère la liste des candidatures de cette annonce
    $listApplications = $em
      ->getRepository('OCPlatformBundle:Application')
      ->findBy(array('advert' => $advert))
    ;

    return $this->render('OCPlatformBundle:Advert:view.html.twig', array(
      'advert'           => $advert,
      'listApplications' => $listApplications
    ));
  }

Ici vous pouvez voir qu'on a utilisé la méthodefindBy(), qui récupère toutes les candidatures selon un tableau de critères. En l'occurrence, le tableau de critères signifie qu'il ne faut récupérer que les candidatures qui sont liées à l'annonce donnée, on en reparlera bien sûr dans le prochain chapitre.

Et bien entendu, il faudrait adapter la vue si vous voulez afficher la liste des candidatures que nous venons de lui passer. Je vous laisse le faire à titre d'entrainement.

Relation Many-To-Many

Présentation

La relation Many-To-Many, ou n..n, correspond à une relation qui permet à plein d'objets d'être en relation avec plein d'autres !

Prenons l'exemple cette fois-ci des annonces de notre plateforme, réparties dans des catégories. Une annonce peut appartenir à plusieurs catégories. À l'inverse, une catégorie peut contenir plusieurs annonces. On a donc une relation Many-To-Many entreAdvert etCategory. La figure suivante schématise tout cela.

Une annonce peut appartenir à plusieurs catégories et une catégorie peut contenir plusieurs annonces
Une annonce peut appartenir à plusieurs catégories et une catégorie peut contenir plusieurs annonces

Cette relation est particulière dans le sens où Doctrine va devoir créer une table intermédiaire. En effet, avec la méthode traditionnelle en base de données, comment feriez-vous pour faire ce genre de relation ? Vous avez une tableadvert, une autre tablecategory, mais vous avez surtout besoin d'une tableadvert_category qui fait la liaison entre les deux ! Cette table de liaison ne contient que deux colonnes :advert_idetcategory_id. Cette table intermédiaire, vous ne la connaîtrez pas : elle n’apparaît pas dans nos entités, et c'est Doctrine qui la crée et qui la gère tout seul !

Encore une fois, pour être sûrs que l'on parle bien de la même chose, créez cette entitéCategory avec au moins un attributname. Voici la mienne :

<?php
// src/OC/PlatformBundle/Entity/Category.php

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Category
{
  /**
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

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

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

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

    return $this;
  }

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

Définition de la relation dans les entités

Annotation

Pour établir cette relation dans vos entités, la syntaxe est la suivante.

Entité propriétaire,Advert :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

/**
 * @ORM\Entity
 */
class Advert
{
  /**
   * @ORM\ManyToMany(targetEntity="OC\PlatformBundle\Entity\Category", cascade={"persist"})
   */
  private $categories;

  // …
}

Entité inverse,Category :

<?php
// src/OC/PlatformBundle/Entity/Category.php

/**
 * @ORM\Entity
 */
class Category
{
  // Nul besoin d'ajouter une propriété ici

  // …
}

J'ai misAdvert comme propriétaire de la relation. C'est un choix que vous pouvez faire comme bon vous semble ici. Mais récupérer les catégories d'une annonce se fera assez souvent, alors que récupérer les annonces d'une catégorie moins. Et puis, pour récupérer les annonces d'une catégorie, on aura surement besoin de personnaliser la requête, donc on le fera de toute façon depuis le CategoryRepository, on en reparlera.

Getter et setters

Dans ce type de relation, il faut soigner un peu plus l'entité propriétaire. Tout d'abord, on a pour la première fois un attribut (ici$categories) qui contient une liste d'objets, et non pas un seul objet. C'est parce qu'il contient une liste d'objets qu'on a mis le nom de cet attribut au pluriel (notez le 's'). Les listes d'objets avec Doctrine2 ne sont pas de simples tableaux, mais desArrayCollection, il faudra donc définir l'attribut comme tel dans le constructeur. UnArrayCollectionest un objet utilisé par Doctrine2, qui a toutes les propriétés d'un tableau normal. Vous pouvez faire unforeachdessus, et le traiter comme n'importe quel tableau. Il dispose juste de quelques méthodes supplémentaires très pratiques, que nous verrons.

Ensuite, le getter est classique et s'appellegetCategories(). Par contre, c'est les setters qui vont différer un peu. En effet,$categoriesest une liste de catégories, mais au quotidien ce qu'on va faire c'est ajouter une à une des catégories à cette liste. Il nous faut donc une méthodeaddCategory()(sans « s », on n'ajoute qu'une seule catégorie à la fois) et non setCategories(). Du coup, il nous faut également une méthode pour supprimer une catégorie de la liste, que l'on appelleremoveCategory().

Ajoutons maintenant le getter et les setters correspondants dans l'entité propriétaire,Advert. Comme tout à l'heure, vous pouvez utiliser la méthodephp app/console doctrine:generate:entities OCPlatformBundle:Advert, ou alors reprendre ce code :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

namespace OC\PlatformBundle\Entity;

// N'oubliez pas ce use
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\AdvertRepository")
 */
class Advert
{
  /**
   * @ORM\ManyToMany(targetEntity="OC\PlatformBundle\Entity\Category", cascade={"persist"})
   */
  private $categories;

  // … vos autres attributs

  // Comme la propriété $categories doit être un ArrayCollection,
  // On doit la définir dans un constructeur :
  public function __construct()
  {
    $this->date = new \Datetime();
    $this->categories = new ArrayCollection();
  }

  // Notez le singulier, on ajoute une seule catégorie à la fois
  public function addCategory(Category $category)
  {
    // Ici, on utilise l'ArrayCollection vraiment comme un tableau
    $this->categories[] = $category;

    return $this;
  }

  public function removeCategory(Category $category)
  {
    // Ici on utilise une méthode de l'ArrayCollection, pour supprimer la catégorie en argument
    $this->categories->removeElement($category);
  }

  // Notez le pluriel, on récupère une liste de catégories ici !
  public function getCategories()
  {
    return $this->categories;
  }


  // … vos autres getters/setters
}

Remplissons la base de données avec les fixtures

Avant de voir un exemple, j'aimerais vous faire ajouter quelques catégories en base de données, histoire d'avoir de quoi jouer avec. Pour cela, petit aparté, nous allons faire une fixture Doctrine ! Cela va nous permettre d'utiliser le bundle qu'on a installé lors du chapitre sur Composer.

Les fixtures Doctrine permettent de remplir la base de données avec un jeu de données que nous allons définir. Cela permet de pouvoir tester avec des vraies données, sans devoir les retaper à chaque fois : on les inscrit une fois pour toutes, et ensuite elles sont toutes insérées en base de données en une seule commande.

Tout d'abord, créons notre fichier de fixture pour l'entitéCategory. Les fixtures d'un bundle se trouvent dans le répertoireDataFixtures/ORM(ouODMpour des documents). Voici à quoi ressemble notre fixtureLoadCategory :

<?php
// src/OC/PlatformBundle/DataFixtures/ORM/LoadCategory.php

namespace OC\PlatformBundle\DataFixtures\ORM;

use Doctrine\Common\DataFixtures\FixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use OC\PlatformBundle\Entity\Category;

class LoadCategory implements FixtureInterface
{
  // Dans l'argument de la méthode load, l'objet $manager est l'EntityManager
  public function load(ObjectManager $manager)
  {
    // Liste des noms de catégorie à ajouter
    $names = array(
      'Développement web',
      'Développement mobile',
      'Graphisme',
      'Intégration',
      'Réseau'
    );

    foreach ($names as $name) {
      // On crée la catégorie
      $category = new Category();
      $category->setName($name);

      // On la persiste
      $manager->persist($category);
    }

    // On déclenche l'enregistrement de toutes les catégories
    $manager->flush();
  }
}

C'est tout ! On peut dès à présent insérer ces données dans la base de données. Voici donc la commande à exécuter :

C:\wamp\www\Symfony>php app/console doctrine:fixtures:load
Careful, database will be purged. Do you want to continue Y/N ?y
  > purging database
  > loading OC\PlatformBundle\DataFixtures\ORM\LoadCategory

Et voilà ! Les cinq catégories définies dans le fichier de fixture sont maintenant enregistrées en base de données, on va pouvoir s'en servir dans nos exemples. Par la suite, on rajoutera d'autres fichiers de fixture pour insérer d'autres entités en base de données : la commande les traitera tous l'un après l'autre.

Exemples d'utilisation

Voici un exemple pour ajouter une annonce existante à plusieurs catégories existantes. Je vous propose de mettre ce code dans notre méthodeeditAction()par exemple :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class AdvertController extends Controller
{
  // …

  public function editAction($id, Request $request)
  {
    $em = $this->getDoctrine()->getManager();

    // On récupère l'annonce $id
    $advert = $em->getRepository('OCPlatformBundle:Advert')->find($id);

    if (null === $advert) {
      throw new NotFoundHttpException("L'annonce d'id ".$id." n'existe pas.");
    }

    // La méthode findAll retourne toutes les catégories de la base de données
    $listCategories = $em->getRepository('OCPlatformBundle:Category')->findAll();

    // On boucle sur les catégories pour les lier à l'annonce
    foreach ($listCategories as $category) {
      $advert->addCategory($category);
    }

    // Pour persister le changement dans la relation, il faut persister l'entité propriétaire
    // Ici, Advert est le propriétaire, donc inutile de la persister car on l'a récupérée depuis Doctrine

    // Étape 2 : On déclenche l'enregistrement
    $em->flush();

    // … reste de la méthode
  }
}

Je vous ai mis un exemple concret d'application pour que vous puissiez vous représenter l'utilisation de la relation dans un vrai cas d'utilisation. Les seules lignes qui concernent vraiment l'utilisation de notre relation Many-To-Many sont les lignes 29 à 31 : la boucle sur les catégories pour ajouter chaque catégorie une à une à l'annonce en question.

Voici un autre exemple pour enlever toutes les catégories d'une annonce. Modifions la méthodedeleteAction()pour l'occasion :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class AdvertController extends Controller
{
  // …

  public function deleteAction($id)
  {
    $em = $this->getDoctrine()->getManager();

    // On récupère l'annonce $id
    $advert = $em->getRepository('OCPlatformBundle:Advert')->find($id);

    if (null === $advert) {
      throw new NotFoundHttpException("L'annonce d'id ".$id." n'existe pas.");
    }

    // On boucle sur les catégories de l'annonce pour les supprimer
    foreach ($advert->getCategories() as $category) {
      $advert->removeCategory($category);
    }

    // Pour persister le changement dans la relation, il faut persister l'entité propriétaire
    // Ici, Advert est le propriétaire, donc inutile de la persister car on l'a récupérée depuis Doctrine

    // On déclenche la modification
    $em->flush();
    
    // ...
  }
}

Notez comment on a récupérer toutes les catégories de notre annonce, un simple$advert->getCategories(), c'est ce que je vous avais promis au début de cette partie sur Doctrine !

Enfin, voici un dernier exemple pour afficher les catégories d'une annonce dans la vue :

{# src/OC/PlatformBundle/Resources/view/Advert/view.html.twig #}

{% if not advert.categories.empty %}
  <p>
    Cette annonce est parue dans les catégories suivantes :
    {% for category in advert.categories %}
      {{ category.name }}{% if not loop.last %}, {% endif %}
    {% endfor %}
  </p>
{% endif %}

Notez principalement :

  • L'utilisation duempty de l'ArrayCollection, pour savoir si la liste des catégories est vide ou non ;

  • Le{{ advert.categories }} pour récupérer les catégories de l'annonce. C'est exactement la même chose que notre$advert->getCategories() côté PHP ;

  • L'utilisation de la variable{{ loop.last }} dans la bouclefor pour ne pas mettre de virgule après la dernière catégorie affichée.

Relation Many-To-Many avec attributs

Présentation

La relation Many-To-Many qu'on vient de voir peut suffire dans bien des cas, mais elle est en fait souvent incomplète pour les besoins d'une application.

Pour illustrer ce manque, rien de tel qu'un exemple : considérons l'entitéProduitd'un site e-commerce ainsi que l'entitéCommande. Une commande contient plusieurs produits, et bien entendu un même produit peut être dans différentes commandes. On a donc bien une relation Many-To-Many. Voyez-vous le manque ? Lorsqu'un utilisateur ajoute un produit à une commande, où met-on la quantité de ce produit ? Si je veux 3 exemplaires de Harry Potter, où mettre cette quantité ? Dans l'entitéCommande? Non cela n'a pas de sens. Dans l'entitéProduit? Non, cela n'a pas de sens non plus. Cette quantité est un attribut de la relation qui existe entreProduitetCommande, et non un attribut deProduit ni deCommande.

Il n'y a pas de moyen simple de gérer les attributs d'une relation avec Doctrine. Pour cela, il faut esquiver en créant simplement une entité intermédiaire qui va représenter la relation, appelons-laCommandeProduit. Et c'est dans cette entité que l'on mettra les attributs de relation, comme notre quantité. Ensuite, il faut bien entendu mettre en relation cette entité intermédiaire avec les deux autres entités d'origine,CommandeetProduit. Pour cela, il faut logiquement faire :Commande One-To-Many CommandeProduit Many-To-One Produit. En effet, une commande (One) peut avoir plusieurs relations avec des produits (Many), plusieursCommandeProduit, donc ! La relation est symétrique pour les produits.

Attention, dans le titre de cette section, j'ai parlé de la relation Many-To-Many avec attributs, mais il s'agit bien en fait de deux relations Many-To-One des plus normales, soyons d'accord. On ne va donc rien apprendre dans ce prochain paragraphe, car on sait déjà faire une Many-To-One, mais c'est une astuce qu'il faut bien connaître et savoir utiliser, donc prenons le temps de bien la comprendre.

J'ai pris l'exemple de produits et de commandes, car c'est plus intuitif pour comprendre l'enjeu et l'utilité de cette relation. Cependant, pour rester dans le cadre de notre plateforme d'annonce, on va faire une relation entre des annonces et des compétences, soit entre les entitésAdvert etSkill, et l'attribut de la relation sera le niveau requis. L'idée est de pouvoir afficher sur chaque annonce la liste des compétences requises pour la mission (Symfony2, C++, Photoshop, etc.) avec le niveau dans chaque compétence (Débutant, Avisé et Expert). On a alors l'analogie suivante :

  • Advert <=>Commande

  • AdvertSkill <=>CommandeProduit

  • Skill <=>Produit

Et donc :Advert One-To-Many AdvertSkill Many-To-One Skill.

Pour cela, créez d'abord cette entitéSkill, avec au moins un attributname. Voici la mienne :

<?php
// src/OC/PlatformBundle/Entity/Skill.php

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\SkillRepository")
 */
class Skill
{
  /**
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

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

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

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

    return $this;
  }

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

Définition de la relation dans les entités

Annotation

Tout d'abord, on va créer notre entité de relation (notreAdvertSkill) comme ceci :

<?php
// src/OC/PlatformBundle/Entity/AdvertSkill.php

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\AdvertSkillRepository")
 */
class AdvertSkill
{
  /**
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

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

  /**
   * @ORM\ManyToOne(targetEntity="OC\PlatformBundle\Entity\Advert")
   * @ORM\JoinColumn(nullable=false)
   */
  private $advert;

  /**
   * @ORM\ManyToOne(targetEntity="OC\PlatformBundle\Entity\Skill")
   * @ORM\JoinColumn(nullable=false)
   */
  private $skill;
  
  // ... vous pouvez ajouter d'autres attributs bien sûr
}

Comme les côtés Many des deux relations Many-To-One sont dansAdvertSkill, cette entité est l'entité propriétaire des deux relations.

Mais, avec une relation unidirectionnelle, on ne pourra pas faire$advert->getAdvertSkills()pour récupérer lesAdvertSkill et donc les compétences ? Ni l'inverse depuis$skill ?

En effet, et c'est pourquoi la prochaine section de ce chapitre traite des relations bidirectionnelles ! En attendant, pour notre relation One-To-Many-To-One, continuons simplement sur une relation unidirectionnelle.

Sachez quand même que vous pouvez tout de même récupérer lesAdvertSkills d'une annonce sans forcément passer par une relation bidirectionnelle. Il suffit d'utiliser la méthodefindBy du repository, comme on l'a déjà fait auparavant :

<?php
// $advert est une instance de Advert

// $advert->getAdvertSkills() n'est pas possible

$listAdvertSkills = $em
  ->getRepository('OCPlatformBundle:AdvertSkill')
  ->findBy(array('advert' => $advert))
;

L'intérêt de la bidirectionnelle ici est lorsque vous voulez afficher une liste des annonces avec leurs compétences. Dans la boucle sur les annonces, vous n'allez pas faire appel à une méthode du repository qui va générer une requête par itération dans la boucle, ça ferait beaucoup de requêtes ! Une relation bidirectionnelle permet de régler ce problème d'optimisation, nous le verrons plus loin dans ce chapitre et dans le prochain.

Getters et setters

Comme d'habitude les getters et setters doivent se définir dans l'entité propriétaire. Ici, rappelez-vous, nous sommes en présence de deux relations Many-To-One dont la propriétaire est l'entitéAdvertSkill. Nous avons donc deux getters et deux setters classiques à écrire. Vous pouvez les générer avec la commandedoctrine:generate:entities OCPlatformBundle:AdvertSkill, ou utiliser le code suivant :

<?php
// src/OC/PlatformBundle/Entity/AdvertSkill.php

namespace OC\PlatformBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\AdvertSkillRepository")
 */
class AdvertSkill
{
  /**
   * @ORM\Column(name="id", type="integer")
   * @ORM\Id
   * @ORM\GeneratedValue(strategy="AUTO")
   */
  private $id;

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

  /**
   * @ORM\ManyToOne(targetEntity="OC\PlatformBundle\Entity\Advert")
   * @ORM\JoinColumn(nullable=false)
   */
  private $advert;

  /**
   * @ORM\ManyToOne(targetEntity="OC\PlatformBundle\Entity\Skill")
   * @ORM\JoinColumn(nullable=false)
   */
  private $skill;

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

  public function setLevel($level)
  {
    $this->level = $level;

    return $this;
  }

  public function getLevel()
  {
    return $this->level;
  }

  public function setAdvert(Advert $advert)
  {
    $this->advert = $advert;

    return $this;
  }

  public function getAdvert()
  {
    return $this->advert;
  }

  public function setSkill(Skill $skill)
  {
    $this->skill = $skill;

    return $this;
  }

  public function getSkill()
  {
    return $this->skill;
  }
}

Remplissons la base de données

Comme précédemment, on va d'abord ajouter des compétences en base de données grâce aux fixtures. Pour faire une nouvelle fixture, il suffit de créer un nouveau fichier dans le répertoireDataFixtures/ORMdans le bundle. Je vous invite à créer le fichierLoadSkill.php:

<?php
// src/OC/PlatformBundle/DataFixtures/ORM/LoadSkill.php

namespace OC\PlatformBundle\DataFixtures\ORM;

use Doctrine\Common\DataFixtures\FixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use OC\PlatformBundle\Entity\Skill;

class LoadSkill implements FixtureInterface
{
  public function load(ObjectManager $manager)
  {
    // Liste des noms de compétences à ajouter
    $names = array('PHP', 'Symfony2', 'C++', 'Java', 'Photoshop', 'Blender', 'Bloc-note');

    foreach ($names as $name) {
      // On crée la compétence
      $skill = new Skill();
      $skill->setName($name);

      // On la persiste
      $manager->persist($skill);
    }

    // On déclenche l'enregistrement de toutes les catégories
    $manager->flush();
  }
}

Et maintenant, on peut exécuter la commande :

C:\wamp\www\Symfony>php app/console doctrine:fixtures:load
Careful, database will be purged. Do you want to continue Y/N ?y
 > purging database
 > loading OC\PlatformBundle\DataFixtures\ORM\LoadCategory
 > loading OC\PlatformBundle\DataFixtures\ORM\LoadSkill

Vous pouvez voir qu'après avoir tout vidé Doctrine a inséré les fixturesLoadCategory puis nos fixturesLoadSkill. Tout est prêt !

Exemple d'utilisation

La manipulation des entités dans une telle relation est un peu plus compliquée, surtout sans la bidirectionnalité. Mais on peut tout de même s'en sortir. Tout d'abord, voici un exemple pour créer une nouvelle annonce contenant plusieurs compétences ; mettons ce code dans la méthodeaddAction():

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

use OC\PlatformBundle\Entity\AdvertSkill;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class AdvertController extends Controller
{
  // …

  public function addAction(Request $request)
  {
    // On récupère l'EntityManager
    $em = $this->getDoctrine()->getManager();

    // Création de l'entité Advert
    $advert = new Advert();
    $advert->setTitle('Recherche développeur Symfony2.');
    $advert->setAuthor('Alexandre');
    $advert->setContent("Nous recherchons un développeur Symfony2 débutant sur Lyon. Blabla…");

    // On récupère toutes les compétences possibles
    $listSkills = $em->getRepository('OCPlatformBundle:Skill')->findAll();

    // Pour chaque compétence
    foreach ($listSkills as $skill) {
      // On crée une nouvelle « relation entre 1 annonce et 1 compétence »
      $advertSkill = new AdvertSkill();

      // On la lie à l'annonce, qui est ici toujours la même
      $advertSkill->setAdvert($advert);
      // On la lie à la compétence, qui change ici dans la boucle foreach
      $advertSkill->setSkill($skill);

      // Arbitrairement, on dit que chaque compétence est requise au niveau 'Expert'
      $advertSkill->setLevel('Expert');

      // Et bien sûr, on persiste cette entité de relation, propriétaire des deux autres relations
      $em->persist($advertSkill);
    }

    // Doctrine ne connait pas encore l'entité $advert. Si vous n'avez pas définit la relation AdvertSkill
    // avec un cascade persist (ce qui est le cas si vous avez utilisé mon code), alors on doit persister $advert
    $em->persist($advert);

    // On déclenche l'enregistrement
    $em->flush();

    // … reste de la méthode
  }
}

L'idée est donc assez simple : lorsque vous voulez lier une annonce à une compétence, il faut d'abord créer cette entité de liaison qu'estAdvertSkill. Vous la liez à l'annonce, à la compétence, et vous définissez tous vos attributs de relations (ici on n'a quelevel). Ensuite il faut persister le tout, et le tour est joué !

Et voici un autre exemple pour récupérer les compétences et leur niveau à partir d'une annonce, la version sans la relation bidirectionnelle donc. Je vous propose de modifier la méthodeviewAction() pour cela :

<?php
// src/OC/PlatformBundle/Controller/AdvertController.php

namespace OC\PlatformBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class AdvertController extends Controller
{
  // …

  public function viewAction($id)
  {
    $em = $this->getDoctrine()->getManager();

    // On récupère l'annonce $id
    $advert = $em
      ->getRepository('OCPlatformBundle:Advert')
      ->find($id)
    ;

    if (null === $advert) {
      throw new NotFoundHttpException("L'annonce d'id ".$id." n'existe pas.");
    }

    // On avait déjà récupéré la liste des candidatures
    $listApplications = $em
      ->getRepository('OCPlatformBundle:Application')
      ->findBy(array('advert' => $advert))
    ;

    // On récupère maintenant la liste des AdvertSkill
    $listAdvertSkills = $em
      ->getRepository('OCPlatformBundle:AdvertSkill')
      ->findBy(array('advert' => $advert))
    ;

    return $this->render('OCPlatformBundle:Advert:view.html.twig', array(
      'advert'           => $advert,
      'listApplications' => $listApplications,
      'listAdvertSkills' => $listAdvertSkills
    ));
  }
}

Et un exemple de ce que vous pouvez utiliser dans la vue pour afficher les compétences et leur niveau :

{# src/OC/PlatformBundle/Resources/view/Advert/view.html.twig #}

{% if listAdvertSkills|length > 0 %}
  <div>
    Cette annonce requiert les compétences suivantes :
    <ul>
      {% for advertSkill in listAdvertSkills %}
        <li>{{ advertSkill.skill.name }} : niveau {{ advertSkill.level }}</li>
      {% endfor %}
    </ul>
  </div>
{% endif %}

Faites bien la différence entre :

  • {{ advertSkill }} qui contient les attributs de la relation, ici le niveau requis via{{ advertSkill.level }} ;

  • Et{{ advertSkill.skill }} qui est la compétence en elle-même, qu'il vous faut utiliser pour afficher le nom de la compétence via{{ advertSkill.skill.name }}.

Attention, dans cet exemple, la méthodefindBy()utilisée dans le contrôleur ne sélectionne que lesAdvertSkill. Donc, lorsque dans la boucle dans la vue on fait{{ advertSkill.skill }}, en réalité Doctrine va effectuer une requête pour récupérer la compétenceSkill associée à cetteAdvertSkill. C'est bien sûr une horreur, car il va faire une requête… par itération dans lefor! Si vous avez 20 compétences attachées à l'annonce, cela ferait 20 requêtes : inimaginable.

Doctrine fait une requête pour chaque Skill à récupérer - voyez l'id qui change à chaque requête.
Doctrine fait une requête pour chaque Skill à récupérer - voyez l'id qui change à chaque requête.

Pour charger lesSkill en même temps que lesAdvertSkill dans le contrôleur, et ainsi ne plus faire de requête dans la boucle, il faut faire une méthode à nous dans le repository deAdvertSkill. On voit tout cela dans le chapitre suivant dédié aux repositories. N'utilisez donc jamais cette technique, attendez le prochain chapitre ! La seule différence dans le contrôleur sera d'utiliser une autre méthode quefindBy(), et la vue ne changera même pas ;) .

Les relations bidirectionnelles

Présentation

Vous avez vu que jusqu'ici nous n'avons jamais modifié l'entité inverse d'une relation, mais seulement l'entité propriétaire. Toutes les relations que l'on vient de faire sont donc des relations unidirectionnelles.

Leur avantage est de définir la relation d'une façon très simple. Mais l'inconvénient est de ne pas pouvoir récupérer l'entité propriétaire depuis l'entité inverse, le fameux$entiteInverse->getEntiteProprietaire()(pour nous,$advert->getApplications()par exemple). Je dis inconvénient, mais vous avez pu constater que cela ne nous a pas du tout empêché de faire ce qu'on voulait ! À chaque fois, on a réussi à ajouter, lister, modifier nos entités et leurs relations.

Mais dans certains cas, avoir une relation bidirectionnelle est bien utile. Nous allons les voir rapidement dans cette section. Sachez que la documentation l'explique également très bien : vous pourrez vous renseigner sur le chapitre sur la création des relations, puis celui sur leur utilisation.

Définition de la relation dans les entités

Pour étudier la définition d'une relation bidirectionnelle, nous allons étudier une relation Many-To-One. Souvenez-vous bien de cette relation, dans sa version unidirectionnelle, pour pouvoir attaquer sa version bidirectionnelle dans les meilleures conditions.

Nous allons ici construire une relation bidirectionnelle de type Many-To-One, basée sur notre exempleAdvert-Application. Mais la méthode est exactement la même pour les relations de type One-To-One ou Many-To-Many.

Annotation

Alors, attaquons la gestion d'une relation bidirectionnelle. L'objectif de cette relation est de rendre possible l'accès à l'entité propriétaire depuis l'entité inverse. Avec une unidirectionnelle, cela n'est pas possible, car on n'ajoute pas d'attribut dans l'entité inverse, ce qui signifie que l'entité inverse ne sait même pas qu'elle fait partie d'une relation.

La première étape consiste donc à rajouter un attribut, et son annotation, à notre entité inverseAdvert :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

/**
 * @ORM\Entity
 */
class Advert
{
  /**
   * @ORM\OneToMany(targetEntity="OC\PlatformBundle\Entity\Application", mappedBy="advert")
   */
  private $applications; // Notez le « s », une annonce est liée à plusieurs candidatures

    // …
}

Bien entendu, je vous dois des explications sur ce que l'on vient de faire.

Commençons par l'annotation. L'inverse d'un Many-To-One est… un One-To-Many, tout simplement ! Il faut donc utiliser l'annotation One-To-Many dans l'entité inverse. Je rappelle que le propriétaire d'une relation Many-To-One est toujours le côté Many, donc, lorsque vous voyez l'annotation Many-To-One, vous êtes forcément du côté propriétaire. Ici on a un One-To-Many, on est bien du côté inverse.

Ensuite, les paramètres de cette annotation. LetargetEntityest évident, il s'agit toujours de l'entité à l'autre bout de la relation, ici notre entitéApplication. LemappedBycorrespond, lui, à l'attribut de l'entité propriétaire (Application) qui pointe vers l'entité inverse (Advert) : c'est leprivate $advert de l'entitéApplication. Il faut le renseigner pour que l'entité inverse soit au courant des caractéristiques de la relation : celles-ci sont définies dans l'annotation de l'entité propriétaire.

Il faut également adapter l'entité propriétaire, pour lui dire que maintenant la relation est de type bidirectionnelle et non plus unidirectionnelle. Pour cela, il faut simplement rajouter le paramètreinversedBydans l'annotation Many-To-One :

<?php
// src/OC/PlatformBundle/Entity/Application.php

namespace OC\PlatformBundle\Entity;

/**
 * @ORM\Entity
 */
class Application
{
  /**
   * @ORM\ManyToOne(targetEntity="OC\PlatformBundle\Entity\Advert", inversedBy="applications")
   * @ORM\JoinColumn(nullable=false)
   */
  private $advert;

  // …
}

Ici, nous avons seulement rajouté le paramètreinversedBy. Il correspond au symétrique dumappedBy, c'est-à-dire à l'attribut de l'entité inverse (Advert) qui pointe vers l'entité propriétaire (Application). C'est donc l'attributapplications.

Tout est bon côté annotation, maintenant il faut également ajouter les getters et setters dans l'entité inverse bien entendu.

Getters et setters

On part d'une relation unidirectionnelle fonctionnelle, donc les getters et setters de l'entité propriétaire sont bien définis.

Dans un premier temps, ajoutons assez logiquement le getter et le setter dans l'entité inverse. On vient de lui ajouter un attribut, il est normal que le getter et le setter aillent de paire. Comme nous sommes du côté One d'un One-To-Many, l'attributapplications est unArrayCollection. C'est donc unaddApplication /removeApplication /getApplications qu'il nous faut. Encore une fois, vous pouvez le générer avecdoctrine:generate:entities OCPlatformBundle:Advert, ou alors vous pouvez utiliser ce code :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

namespace OC\PlatformBundle\Entity;

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

/**
 * @ORM\Entity(repositoryClass="OC\PlatformBundle\Entity\AdvertRepository")
 */
class Advert
{
  /**
   * @ORM\OneToMany(targetEntity="OC\PlatformBundle\Entity\Application", mappedBy="advert")
   */
  private $applications; // Notez le « s », une annonce est liée à plusieurs candidatures

  // … vos autres attributs

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

  public function addApplication(Application $application)
  {
    $this->applications[] = $application;

    return $this;
  }

  public function removeApplication(Application $application)
  {
    $this->applications->removeElement($application);
  }

  public function getApplications()
  {
    return $this->applications;
  }

  // …
}

Maintenant, il faut nous rendre compte d'un petit détail. Voici une petite problématique, lisez bien ce code :

<?php
// Création des entités
$advert = new Advert;
$application = new Application;

// On lie la candidature à l'annonce
$advert->addApplication($application);

Que retourne$application->getAdvert()?

Rien ! En effet, pour qu'un$application->getAdvert()retourne effectivement une annonce, il faut d'abord le lui définir en appelant$application->setAdvert($advert), c'est logique !

C'est logique en soi, mais du coup dans notre code cela va être moins beau : il faut en effet lier la candidature à l'annonce et l'annonce à la candidature. Comme ceci :

<?php
// Création des entités
$advert = new Advert;
$application = new Application;

// On lie la candidature à l'annonce
$advert->addApplication($application);

// On lie l'annonce à la candidature
$application->setAdvert($advert);

Mais ces deux méthodes étant intimement liées, on doit en fait les imbriquer. En effet, laisser le code en l'état est possible, mais imaginez qu'un jour vous oubliiez d'appeler l'une des deux méthodes ; votre code ne sera plus cohérent. Et un code non cohérent est un code qui a des risques de contenir des bugs. La bonne méthode est donc simplement de faire appel à l'une des méthodes depuis l'autre. Voici concrètement comment le faire en modifiant les setters dans l'une des deux entités :

<?php
// src/OC/PlatformBundle/Entity/Advert.php

namespace OC\PlatformBundle\Entity;

/**
 * @ORM\Entity
 */
class Advert
{
  // …

  public function addApplication(Application $application)
  {
    $this->applications[] = $application;

    // On lie l'annonce à la candidature
    $application->setAdvert($this);

    return $this;
  }

  public function removeApplication(Application $application)
  {
    $this->applications->removeElement($application);

    // Et si notre relation était facultative (nullable=true, ce qui n'est pas notre cas ici attention) :        
    // $application->setAdvert(null);
  }

  // …
}

Notez qu'ici j'ai modifié un côté de la relation (l'inverse en l'occurrence), mais surtout pas les deux ! En effet, siaddApplication()exécutesetAdvert(), qui exécute à son touraddApplication(), qui… etc. On se retrouve avec une boucle infinie.

Bref, l'important est de se prendre un côté (propriétaire ou inverse, cela n'a pas d'importance), et de l'utiliser. Par utiliser, j'entends que dans le reste du code (contrôleur, service, etc.) il faudra exécuter$advert->addApplication()qui garde la cohérence entre les deux entités. Il ne faudra jamais exécuter$application->setAdvert(), car lui ne garde pas la cohérence ! Retenez : on modifie le setter d'un côté, et on utilise ensuite ce setter-là. C'est simple, mais important à respecter.

Pour conclure

Le chapitre sur les relations Doctrine touche ici à sa fin.

Pour maîtriser les relations que nous venons d'apprendre, il faut vous entraîner à les créer et à les manipuler. N'hésitez donc pas à créer des entités d'entraînement, et à voir leur comportement dans les relations.

Si vous voulez plus d'informations sur les fixtures que l'on a rapidement abordées lors de ce chapitre, je vous invite à lire la page de la documentation du bundle : http://symfony.com/fr/doc/current/bund [...] le/index.html

Rendez-vous au prochain chapitre pour apprendre à récupérer les entités depuis la base de données à votre guise, grâce aux repositories !

En résumé

  • Les relations Doctrine révèlent toute la puissance de l'ORM.

  • Dans une relation entre deux entités, l'une est propriétaire de la relation et l'autre est inverse. Cette notion est purement technique.

  • Une relation est dite unidirectionnelle si l'entité inverse n'a pas d'attribut la liant à l'entité propriétaire. On met en place une relation bidirectionnelle lorsqu'on a besoin de cet attribut dans l'entité inverse (ce qui arrivera pour certains formulaires, etc.).

Exemple de certificat de réussite
Exemple de certificat de réussite