📘

Relations entre entités

Relation onetoumani / manitouone :

Un objet peut être associé à plusieurs autres objets.

Relation manitoumani :

Plusieurs objets peuvent être associés à plusieurs objets.

—> Une table d’association associe les id de la première table avec les id de la deuxième table.

Relation onetouone :

Un objet peut être associé à un et un seul objet.

Pas très utilisé : souvent on lui met en attribut et pas dans une table.

Utile par exemple pour l’authentification.

Relation entre objets PHP :

Une entité est liée à une autre lorsqu’elle porte un attribut qui porte le même nom que l’entité à laquelle elle est liée (on peut typer en PHP8).

BP : Si l’attribut est en fait un tableau d’objets, on le met au pluriel.
⚠️
On travaille avec des objets. Deux instances sont liées / en relation —> on ne parle pas d’id.

Créer une relation entre deux entités


L’entité liée est importée.

Les attributs respectifs portent des attributs (eh oui c’est le même mot……) @ORM\TypeRelation et leurs getter/setter ont été ajoutés.

ℹ️
Dans le cas d’une collection d’instances (”mani” dans la relation), les getter/setter sont sous la forme addEntite() et removeEntite() avec l’initialisation du tableau dans un constructeur __construct().
// entite Country, liée en onetoumani avec Movie
<?php

namespace App\Entity;

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

#[ORM\Entity(repositoryClass: CountryRepository::class)]
class Country
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column]
    private ?int $code = null;

    #[ORM\Column(length: 2)]
    private ?string $alpha2 = null;

    #[ORM\Column(length: 3)]
    private ?string $alpha3 = null;

    #[ORM\Column(length: 45)]
    private ?string $name_english = null;

    #[ORM\Column(length: 45)]
    private ?string $name_french = null;

    #[ORM\OneToMany(mappedBy: 'country', targetEntity: Movie::class)]
    private Collection $movies;

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

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

    public function getCode(): ?int
    {
        return $this->code;
    }

    public function setCode(int $code): self
    {
        $this->code = $code;

        return $this;
    }

    public function getAlpha2(): ?string
    {
        return $this->alpha2;
    }

    public function setAlpha2(string $alpha2): self
    {
        $this->alpha2 = $alpha2;

        return $this;
    }

    public function getAlpha3(): ?string
    {
        return $this->alpha3;
    }

    public function setAlpha3(string $alpha3): self
    {
        $this->alpha3 = $alpha3;

        return $this;
    }

    public function getNameEnglish(): ?string
    {
        return $this->name_english;
    }

    public function setNameEnglish(string $name_english): self
    {
        $this->name_english = $name_english;

        return $this;
    }

    public function getNameFrench(): ?string
    {
        return $this->name_french;
    }

    public function setNameFrench(string $name_french): self
    {
        $this->name_french = $name_french;

        return $this;
    }

    /**
     * @return Collection<int, Movie>
     */
    public function getMovies(): Collection
    {
        return $this->movies;
    }

    public function addMovie(Movie $movie): self
    {
        if (!$this->movies->contains($movie)) {
            $this->movies->add($movie);
            $movie->setCountry($this);
        }

        return $this;
    }

    public function removeMovie(Movie $movie): self
    {
        if ($this->movies->removeElement($movie)) {
            // set the owning side to null (unless already changed)
            if ($movie->getCountry() === $this) {
                $movie->setCountry(null);
            }
        }

        return $this;
    }
}

Relation bidirectionnelle

Dans la majorité des cas, c’est ce qu’on veut. Les deux classes sont au courant de la relation. Un seul côté est propriétaire, indiqué avec mappedBy tandis que l’autre est indiqué avec inversedBy.

Le côté qui porte le “one” de la relation est propriétaire. Si onetouone , on peut faire comme on veut (Symfony décide souvent pour nous à la création). Si manitoumani, Symfony crée la table d’association, à laquelle on ne peut pas ajouter de colonnes.

Traduction en base de données avec Doctrine :

@ORM\ManyToOne et @ORM\OneToMany :

• la clé étrangère s'ajoute dans la table côté Many

@ORM\OneToOne() :

• on choisit où s'ajoute la clé étrangère

@ORM\ManyToMany() :

• Doctrine crée la table de pivot !

• Le choix de nom de table est personnalisable

• Impossibilité d'ajouter des colonnes supplémentaires

Récupérer une entité associée à une autre


Il suffit d’utiliser l’attribut objet de l’entité.

Exemples :

{% for movie in movies %}
    <li>
			<a href="{{ path('movie_detail', {'id': movie.id}) }}">
				{{ movie.title }}
			</a>
			 ({{ movie.country.nameFrench }}) sorti en {{ movie.releaseYear }}
		</li>
{% else %}
    Aucun film n'a été ajouté pour l'instant.
{% endfor %}
{% for season in serie.seasons %}
	<article>
		<img 
			src="{{ asset('img/poster/seasons/'~season.poster }}" 
			alt="{{ serie.name }}"
			title="{{ serie.seasons | length }}"
		>
	</article
{% endfor %}

Améliorer les requêtes

Doctrine se charge des requêtes, mais c’est souvent moche : au lieu de faire une jointure, il fait plusieurs requêtes SELECT. Dans ce cas-là, on fait des requêtes personnalisées : possible en DQL ou avec le QueryBuilder.

Exemple pour les saisons des séries :

On crée une méthode findAllWithSeasons() avec une jointure sur l’attribut s.seasons (dont l’alias est seasons), trié par ordre décroissant de popularité.

Puis on crée une requête et on récupère le résultat qu’on retourne.

public function findAllWithSeasons() {
	$queryBuilder = ($this->createQueryBuilder('s'))
		->leftJoin('s.seasons', 'seasons'); // pour aussi avoir les series sans saison
		->addSelect('seasons')
		->addOrderBy('s.popularity', 'DESC');
	$query = $queryBuilder->getQuery();
	$query->setMaxResults(10);
	return $query->getResult();
}
⚠️
Après une jointure, il faut préciser qu’on veut l’utiliser pour afficher la colonne sur laquelle on fait la jointure.

On utilise la méthode findAllWithSeasons() à la place du findAll().

Gérer la pagination

Paginator est un outil pour faire le group by sur un ensemble d’enregistrements et les compter correctement (ex: plusieurs saisons pour une seule série).

⚠️
Avec le setMaxResults(), on obtient le nombre de lignes (et pas le nombre de séries !!).

On ajoute le paginator à la fin et c’est lui qu’on retourne.

public function findAllWithSeasons() {
	$queryBuilder = ($this->createQueryBuilder('s'))
		->leftJoin('s.seasons', 'seasons'); // pour aussi avoir les series sans saison
		->addSelect('seasons')
		->addOrderBy('s.popularity', 'DESC');
	$query = $queryBuilder->getQuery();
	$query->setMaxResults(10);
	$paginator = new Paginator($query);
	return $paginator;
}
📌
Pour aller plus loin : https://www.doctrine-project.org/projects/doctrine-orm/en/2.14/tutorials/pagination.html

Afficher un champ objet dans un formulaire

Dans la classe du formulaire,

// MovieFormType (relation avec Country)
<?php

namespace App\Form;

use App\Entity\Country;
use App\Entity\Movie;
use App\Repository\CountryRepository;
use Doctrine\Migrations\Tests\Provider\C;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class MovieFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                "label" => "Titre ",
                "required" => true
            ])
            ->add('releaseYear', IntegerType::class, ["label" => "Année de sortie "])
            ->add('country', EntityType::class, [
                "class" => Country::class,
                "choice_label" => "nameFrench",
                "label" => "Pays "
            ])
            ->add('wasSeen', CheckboxType::class, [
                "label" => "Déjà vu ?",
                "required" => false
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Movie::class,
        ]);
    }
}

Enregistrer, supprimer et automatiser


Pour enregistrer une entité associée à une autre, il faut faire attention à l’ordre : on crée d’abord l’instance de l’entité à associer puis l’entité qui la contiendra et on lie les deux grâce au setter dédié.

Exemple : enregistrer un article avec ses commentaires

Dans le SerieController,

public function enregistrerArticleAvecCommentaires(
	EntityManagerInterface $em
){
	$commentaire = (new Commentaire())
		->setContent("Un super commentaire gentil pour une fois");
	$article = (new Article())
		->setTitle("Un article sur la zététique"
		->setCommentaire($commentaire); //associe le commentaire avec l'article
	$em->persist($commentaire);
	$em->persist($article);
	$em->flush();
	return $this->render('unTwig.html.twig');
}

Pour supprimer une entité associée à une autre, il faut faire attention à l’ordre : on supprime d’abord l’entité liée puis l’entité principale (qui contient l’autre).

Exemple : supprimer un article avec ses commentaires

Dans le SerieController,

public function supprimerArticleAvecCommentaires(
	EntityManagerInterface $em
){
	$em->remove($commentaire);
	$em->remove($article);
	$em->flush();
	return $this->render('unTwig.html.twig');
}

Pour automatiser ces processus, on peut utiliser l’option cascade dans l’attribut #[ORM\...] : persist | remove | merge | detach | refresh | all.

#[ORM\OneToMany(mappedBy: 'country', targetEntity: Movie::class, cascade:['persist', 'remove'])]
📌
Pour aller plus loin : - https://www.doctrine-project.org/projects/doctrine-orm/en/2.14/reference/working-with-associations.html#transitive-persistence-cascade-operations - https://yusufbiberoglu.medium.com/cascade-persist-remove-in-doctrine-559ac967e451