API PLatform - Gestion des relations entre les entités 2/2 Relations plusieurs-plusieurs, n-aire, porteuses
Introduction
Dans la première partie de ce complément, vous avez appris à gérer les différentes relations un-plusieurs et un-un.
Dans cette seconde partie, nous allons étudier la gestion des relations types plusieurs-plusieurs (binaires), ou qui impliquent plus de deux entités (ternaires, etc), incluant ou non des données (association porteuse).
Ce type de relation peut s’avérer compliquée de bien des manières !
Attention, avant de continuer, il est fortement recommandé d’avoir bien suivi la première partie de ce complément. Nous allons reprendre (et étendre) l’application développée dans la première partie.
Les relations plusieurs-plusieurs (ManyToMany), associations porteuses et n-aire
Nous allons faire évoluer notre modélisation en incluant des clubs auxquels peut s’inscrire un joueur. Un joueur peut s’inscrire à différents clubs et un club peut posséder plusieurs membres.
On va modéliser cela avec une association binaire plusieurs-plusieurs (ManyToMany) simple :
Ce qui donnerait alors le schéma relationnel suivant :
- Joueur(id, nom, prenom, #idVille)
 - Club(id, nom)
 - Inscription(#idJoueur, #idClub)
 
En effet, lors d’association n-aire, on crée une table dont la clé primaire est composée de clés étrangères référençant les entités qui participent à l’association.
Au niveau de la conception, on peut choisir d’avoir des collections d’un seul côté (liste de clubs dans Joueur ou bien liste de joueurs dans Club) ou bien des deux côtés (bidirectionnelle).
Comme Doctrine gère correctement les propriétés bidirectionnelles, nous allons décider d’avoir à la fois avoir 
une collection listant les clubs d’un joueur (dans Joueur) et une autre collection listant les membres d’un club (dans Club).
On peut aussi créer des routes spéciales pour lister les clubs d’un joueur et inversement (/joueurs/{id}/clubs et/ou /clubs/{id}/membres).
Ce qui donnera, du côté de l’application :
#[ORM\Entity(repositoryClass: ClubRepository::class)]
#[ApiResource]
#[ApiResource(
    uriTemplate: '/joueurs/{idJoueur}/clubs',
    operations: [
        new GetCollection(),
    ],
    uriVariables: [
        'idJoueur' => new Link(
            toProperty: 'membres',
            fromClass: Joueur::class,
        )
    ],
    normalizationContext: ["groups" => ['club:read']],
    denormalizationContext: ["groups" => ['club:write']]
)]
class Club
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['club:read'])]
    private ?int $id = null;
    #[ORM\Column(length: 255)]
    #[Groups(['club:read', 'club:write'])]
    private ?string $nom = null;
    /**
     * @var Collection<int, Joueur>
     */
    #[ORM\ManyToMany(targetEntity: Joueur::class, inversedBy: 'clubs')]
    #[Groups(['club:read', 'club:write'])]
    private Collection $membres;
    //Methodes...
}
#[ORM\Entity(repositoryClass: JoueurRepository::class)]
#[ApiResource(
    normalizationContext: ["groups" => ['joueur:read', 'ville:read', 'resultat:read']],
    denormalizationContext: ["groups" => ['joueur:write', 'resultat:write']]
)]
#[ApiResource(
    uriTemplate: '/clubs/{idClub}/membres',
    operations: [
        new GetCollection(),
    ],
    uriVariables: [
        'idClub' => new Link(
            toProperty: 'clubs',
            fromClass: Club::class,
        )
    ],
    normalizationContext: ["groups" => ['joueur:read']],
)]
...
class Joueur
{
    ...
    /**
     * @var Collection<int, Club>
     */
    #[ORM\ManyToMany(targetEntity: Club::class, mappedBy: 'membres')]
    #[Groups(['joueur:read', 'joueur:write'])]
    private Collection $clubs;
    ...
}
Grâce à l’utilisation de ManyToMany, doctrine va automatiquement créer (lors de la migration) 
une table correspondant à notre table Inscription du schéma relationnel :
Inscription(#idJoueur, #idClub)
Mais quels sont ces fameux problèmes que nous allons rencontrer ?
En effet, actuellement, avec cette modélisation, il est tout à fait possible :
- 
    
De créer un joueur et de fournir (éventuellement) la liste des clubs auxquels il est inscrit :
POST https://localhost/api/joueurs { "nom": "Tarembois", "prenom": "Guy", "ville": "/api/villes/1", "clubs" : [ "/api/clubs/1", "/api/clubs/2" ] } - 
    
De créer un club et de fournir (éventuellement) la liste des joueurs membres :
POST https://localhost/api/clubs { "nom": "Club Sandwich", "membres" : [ "/api/joueurs/2", "/api/joueurs/7", "/api/joueurs/13", ] } - 
    
De modifier complètement la liste des clubs auxquels un joueur est inscrit :
PATCH https://localhost/api/joueurs/17 { "clubs" : [ "/api/clubs/5", "/api/clubs/7", "/api/clubs/33", ] } - 
    
De modifier complètement la liste des joueurs membres d’un club.
PATCH https://localhost/api/clubs/35 { "clubs" : [ "/api/joueurs/9", "/api/joueurs/13", ] } 
Mais comment faire pour associer (inscrire) simplement un joueur à un club ? Ou pour supprimer cette association (désinscription) ?
Comme montré dans l’exemple avec PATCH, cela est techniquement possible, mais il faut renvoyer à chaque fois la liste complète des clubs (ou des membres). Cela est très contraignant (et lourd).
De plus, ici, nous avons affaire à une association binaire simple. Gérer ces associations dans des cas plus évolués semble complexe (n-aire avec n > 2 et/ou porteuse de données).
Dans l’idéal, il faudrait trouver un moyen simple de créer ou supprimer une inscription (d’un joueur à un club) sans avoir à 
effectuer un PATCH du joueur ou du club.
C’est donc la problématique que nous allons essayer de résoudre par la suite avec différentes solutions que nous allons explorer :
- 
    
Utilisation d’une “ressource virtuelle” pour gérer les relations
ManyToManysimples (non porteuses, et pas de relations ternaires ou plus). - 
    
Création d’une entité Inscription dédiée afin de diviser la relation
ManyToManyen relationsManyToOne(permet de gérer tous les cas de figure):- 
        
Avec une entité coordinatrice possédant son propre identifiant (solution à privilégier). Cela induit à une adaptation du modèle E/A et du schéma relationnel et demande aussi de gérer certaines contraintes afin de conserver la cohérence des données.
 - 
        
Avec une entité possédant une clé composite (solution déconseillée).
 
 - 
        
 
Utilisation d’une ressource virtuelle pour les relations Many-To-Many simples
La mise en place de cette solution permet d’introduire des routes pour créer et supprimer des associations entre un joueur et en club.
Notre schéma relationnel, les tables et les relations existantes ne seront pas affectés.
Dans ce scénario, on conserve donc la relation ManyToMany entre Joueur et Club.
La solution consiste à créer une classe entité “virtuelle”, c’est-à-dire qui ne sera pas stockée en base de données. Cette entité servira alors à prendre en charge des routes customisées dont nous traiterons la logique avec des StateProvider et des StateProcessor.
La forme des routes qui permettront d’agir sur cette ressource virtuelle sera composée des identifiants des deux entités en relation.
Dans notre exemple :
PUT /joueurs/{idJoueur}/clubs/{idClub}: pour ajouter un joueur à un club.DELETE /joueurs/{idJoueur}/clubs/{idClub}: pour retirer un joueur d’un club.GET /joueurs/{idJoueur}/clubs/{idClub}: pour vérifier si un joueur est inscrit à un certain club.
Attention, cette forme de route peut sembler ambiguë : on pourrait croire qu’on modifie (ou qu’on supprime) les données d’un club (d’un joueur) : ce n’est pas le cas, nous modifions ou nous supprimons seulement l’association entre ces deux entités.
On utilise ici le verbe PUT au lieu de POST, car on ne crée pas vraiment de ressource, on fait une mise à jour en liant deux entités déjà existantes.
Nous allons commencer par créer les bases de notre entité virtuelle. Elle sera nommée Inscription et sera composée d’un Joueur et d’un Club.
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Put;
#[ApiResource(
    uriTemplate: '/joueurs/{idJoueur}/clubs/{idClub}',
    operations: [
        new Put(
            description: "Inscrit un joueur à un club",
            deserialize: false,
            allowCreate: true
        ),
        new Delete(
            description: "Retire un joueur d'un club"
        ),
        new Get(
            description: "Permet de vérifier si un joueur est inscrit à un club"
        ),
    ],
    uriVariables: [
        'idJoueur' => new Link(
            fromClass: Joueur::class
        ),
        'idClub' => new Link(
            fromClass: Club::class
        ),
    ]
)]
class Inscription
{
    #[ApiProperty(writable: false)]
    private ?Joueur $joueur = null;
    #[ApiProperty(writable: false)]
    private ?Club $club = null;
    public function getJoueur(): ?Joueur
    {
        return $this->joueur;
    }
    public function getClub(): ?Club
    {
        return $this->club;
    }
    public function setJoueur(?Joueur $joueur): self
    {
        $this->joueur = $joueur;
        return $this;
    }
    public function setClub(?Club $club): self
    {
        $this->club = $club;
        return $this;
    }
}
Quelques commentaires sur cette classe :
- Le paramètre 
uriTemplatenous permet de définir la forme de notre route. - Le paramétrage dans 
uriVariablesne doit pas utiliser de paramètrestoPropertyet/oufromPorperty. Par contre, il est important de préciser à quelles classes correspondent ces paramètres (pour la documentation de l’API) - Le paramètre 
descriptiondans chaque opération permet de documenter l’API. - Le paramètre 
deserialize : falsedans l’opérationPUTpermet d’autoriser l’envoi d’un payload vide. - Le paramètre 
allowCreate : truedans l’opérationPUTpermet d’autoriser la création d’une ressource avec le verbePUTsi elle n’existe pas au préalable. - Il n’y a pas de relations 
ManyToOnesur$joueuret$club, car l’entité n’est pas stockée en base de données. - On interdit l’écriture (via le 
payload) de$joueuret$club: leurs identifiants seront fournis via l’URI. 
Maintenant, il faut coder diverses classes pour gérer la logique de notre entité virtuelle :
- 
    
Un StateProvider : classe qui permet de récupérer les données de l’entité (dans la base de données), à partir des données de la route. Elle est utilisée dans les opérations
GET,PUT,PATCHetDELETE. Par défaut, API Platform utilise un provider par défaut, mais s’il y a besoin d’effectuer un traitement particulier, on peut coder et fournir son propre StateProvider - 
    
Un StateProcessor pour l’opération
PUT. Pour rappel, un StateProcessor est une classe qui permet de traiter et modifier un objet lors d’opérations qui vont changer son état. Elle est donc utilisée dans le cadre des opérationsPOST,PUTetPATCH. Dans le TD4, nous en avions utilisé deux pour affecter automatiquement l’auteur d’un message, mais aussi pour hacher le mot de passe de l’utilisateur. - 
    
Un StateProcessor pour l’opération
DELETE. 
En résumé, un StateProvider permet de récupérer et traiter l’objet avant de le renvoyer au client, et 
un StateProcessor de traiter l’objet après l’envoi des données par client et avant sa sauvegarde.
Commençons par coder notre StateProvider. Le traitement effectué par cette classe est le suivant :
- 
    
Récupérer les identifiants
idJoueuretidClubdans l’URI. - 
    
Récupérer le joueur et le club concernés.
 - 
    
Lever des exceptions
NotFoundHttpExceptionsi le joueur ou le club n’existent pas. - 
    
Retourner
nullsi le joueur concerné n’est pas inscrit au club concerné. - 
    
Sinon, construire un objet
Inscriptionen affectant leJoueuret leClubrécupérés puis le retourner. 
API Platform fournit une commande pour créer la base d’un StateProvider :
php bin/console make:state-provider InscriptionProvider
Ce qui créé la classe InscriptionProvider dans le dossier src/State.
Il ne reste plus qu’à la compléter :
namespace App\State;
class InscriptionProvider implements ProviderInterface
{
    //Injections des repositories
    public function __construct(private JoueurRepository $joueurRepository, private ClubRepository $clubRepository)
    {
    }
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        //$uriVariables contient les valeurs des variables fournies au travers de l'URI de la route
        $idJoueur = $uriVariables["idJoueur"];
        $joueur = $this->joueurRepository->find($idJoueur);
        if(!$joueur) {
            throw new NotFoundHttpException("Joueur inexistant.");
        }
        $idClub = $uriVariables["idClub"];
        $club = $this->clubRepository->find($idClub);
        if(!$club) {
            throw new NotFoundHttpException("Club inexistant.");
        }
        //On regarde si le joueur n'est pas inscrit au club
        if(!$joueur->getClubs()->contains($club)) {
            return null;
        }
        //Si le joueur est bien inscrit au club, on créé l'objet inscription, on le configure puis on le retourne
        $inscription = new Inscription();
        $inscription->setJoueur($joueur);
        $inscription->setClub($club);
        return $inscription;
    }
}
Ensuite, on va créer le premier StateProcessor permettant de gérer l’opération PUT, avec le traitement suivant :
- 
    
Si l’inscription (entre le joueur et le club) existe déjà, on ne fait rien et on la retourne.
 - 
    
Sinon :
- 
        
On récupère le joueur et le club concernés.
 - 
        
On lève des exceptions
NotFoundHttpExceptionsi le joueur ou le club n’existent pas. - 
        
On ajoute le club au joueur, avec la méthode
addClub(automatiquement générée dansJoueurpar Symfony). - 
        
On sauvegarde la mise à jour du joueur en utilisant la méthode
flushdu serviceEntityManager. - 
        
On construit un objet
Inscriptionen affectant leJoueuret leClubrécupérés et on le retourne. 
 - 
        
 
Comme notre opération PUT utilise au préalable notre StateProvider, on peut savoir si l’inscription existe déjà ou non.
Un objet $data est fourni au StateProcessor contenant les données de la ressource ciblée dans l’URL (ou null si elle n’existe pas).
Pour rappel, API Platform fournit une commande pour créer la base d’un StateProcessor :
php bin/console make:state-processor InscriptionPutProcessor
Ce qui créé la classe InscriptionPutProcessor dans le dossier src/State.
Il ne reste plus qu’à la compléter :
namespace App\State;
class InscriptionPutProcessor implements ProcessorInterface
{
    //Injection des repositories et de du service EntityManager
    public function __construct(
        private JoueurRepository $joueurRepository,
        private ClubRepository $clubRepository,
        private EntityManagerInterface $entityManager,
    )
    {}
    //$data est un objet Inscription fourni par le StateProvider.
    //Dans ce contexte (PUT avec allowCreate: true), il peut être null si le joueur n'était pas déjà inscrit au club en question.
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        //Si l'inscription n'existe pas déjà (null retourné par le StateProvider)
        if(!$data) {
            //$uriVariables contient les valeurs des variables fournies au travers de l'URI de la route
            $idJoueur = $uriVariables["idJoueur"];
            $joueur = $this->joueurRepository->find($idJoueur);
            if(!$joueur) {
                throw new NotFoundHttpException("Joueur inexistant.");
            }
            $idClub = $uriVariables["idClub"];
            $club = $this->clubRepository->find($idClub);
            if(!$club) {
                throw new NotFoundHttpException("Club inexistant.");
            }
            //On créé l'objet Inscription à retourner au client
            $data = new Inscription();
            $data->setJoueur($joueur);
            $data->setClub($club);
            //On ajoute le club à la collection de clubs du joueur
            $joueur->addClub($club);
            //On sauvegarde les changements
            $this->entityManager->flush();
        }
        return $data;
    }
}
Enfin, il ne reste plus qu’à créer le StateProcessor pour gérer l’opération DELETE.
Celui-ci va être rapide à coder : le StateProvider doit déjà nous fournit un objet valide. 
Nous n’avons donc pas besoin de vérifier l’existence de l’inscription, car une exception NotFoundHttpException 
aura été retournée avant d’arriver au traitement de notre StateProcessor.
Techniquement, cela aurait dû être le cas aussi pour PUT. Mais comme nous avons ajouté le paramètre allowCreate: true, 
nous avons autorisé d’exécuter le traitement même si la ressource ciblée n’existe pas.
Ici, le traitement va être simple : retirer le club de la collection de clubs du joueur puis sauvegarder les modifications :
php bin/console make:state-processor InscriptionDeleteProcessor
namespace App\State;
class InscriptionDeleteProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager
    )
    {}
    //$data est un objet Inscription fourni par le StateProvider.
    //Dans ce contexte (DELETE), il ne peut pas être null, sinon une exception NotFoundHttpException aurait été levée avant d'arriver ici.
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
    {
        $data->getJoueur()->removeClub($data->getClub());
        $this->entityManager->flush();
    }
}
Enfin, l’étape finale est de modifier notre entité/ressource virtuelle Inscription afin d’y attacher notre StateProvider et
nos deux StateProcessor au niveau des opérations concernées :
namespace App\Entity;
#[ApiResource(
    ...
    operations: [
        new Put(
            description: "Inscrit un joueur à un club",
            deserialize: false,
            allowCreate: true,
            processor: InscriptionPostProcessor::class
        ),
        new Delete(
            description: "Retire un joueur d'un club",
            processor: InscriptiondeleteProcessor::class
        ),
        ...
    ],
    ...
    provider: InscriptionProvider::class,
)]
class Inscription
{
    ...
}
À noter qu’on pourrait aussi (en plus ou à la place) ajouter des routes dans l’autres sens (de club vers joueur) en utilisant le même provider et les mêmes processors.
Bref, tout est prêt ! Les exemples suivants fonctionnent :
Ajout du joueur 1 au club 2 :
PUT /api/joueurs/1/clubs/2
Vérifier si le joueur 1 est inscrit club 2 :
GET /api/joueurs/1/clubs/2
Retirer le joueur 1 du club 2 :
DELETE /api/joueurs/1/clubs/2
Avantages de cette solution
- 
    
Permet de conserver l’utilisation de relations
ManyToMany. - 
    
Pas de nouvelle entité stockée.
 - 
    
Permet de respecter le modèle E/A et le schéma relationnel d’origine.
 
Inconvénients
- 
    
Pas mal de code à écrire (providers, processors).
 - 
    
Route un peu ambiguë.
 - 
    
Ne fonctionne pas pour les ternaires (et plus) et pour les associations porteuses de données. Dans ce cas, on doit abandonner l’usage des relations
ManyToMany. 
Utilisation d’une entité coordinatrice dédiée
Cette seconde solution est plus généraliste, car elle s’adapte facilement à tous les cas de figures : associations binaires simples, ternaires (ou plus), associations porteuses de données…
L’idée est de remplacer les relations ManyToMany (plusieurs-plusieurs) par des relations OneToMany vers une entité 
coordinatrice Inscription avec un identifiant (clé) numérique simple et deux relations (ManyToOne) vers 
joueur et club (clés étrangères, mais ne font pas partie de la clé primaire).
En adaptant notre modèle E/A et notre schéma relationnel, cela donne alors la modélisation suivante :
Ce qui donnerait alors le schéma relationnel suivant :
- Joueur(id, nom, prenom, #idVille)
 - Club(id, nom)
 - Inscription(id, #idJoueur, #idClub).
 
En termes de conception, on adapte aussi le diagramme de classes de conception en conséquence :
À noter que la bidirectionnelle est facultative. On pourrait éventuellement se passer des collections inscriptions d’un côté ou même des deux (on pourra toujours créer des routes dédiées pour trouver l’information).
Notre entité Inscription possédera donc deux relations ManyToOne : une vers le Joueur et l’autre vers Club.
Du côté de Joueur et/ou Club, on a alors des collections d’entités Inscription 
(on supprime les relations ManyToOne entre Club et Joueur).
Avec cette modélisation, on peut alors créer, supprimer ou mettre à jour des entités Inscription en utilisant leurs identifiants propres.
Cependant, cette méthode ne respecte pas vraiment la modélisation initiale du modèle E/A, car elle utilise une entité coordinatrice au lieu d’une association binaire qui possède un identifiant naturel (composé des deux clés étrangères).
Dans ce cas, pour conserver la cohérence des données, il faut interdire le fait d’avoir plusieurs fois le même couple de valeurs pour les clés étrangères (un joueur ne doit pas pouvoir être inscrit plusieurs fois au même club).
Il faut donc explicitement gérer la contrainte d’unicité (et NOT NULL) sur le couple (#idJoueur, #idClub).
Il faudra aussi spécifier que #idJoueur et #idClub ne peuvent pas être nuls.
Cela est très facile avec Symfony et Doctrine.
Concrètement, la mise en place de cette solution se fait en plusieurs étapes :
- 
    
On n’associe pas
JoueuretClubavec uneManyToMany. - 
    
À la place, on crée une nouvelle entité
Inscriptioncomposée :- 
        
D’un identifiant (généré automatiquement quand on crée l’entité avec la commande
make:entity). - 
        
D’une relation
ManyToOneavecJoueurqui ne doit pas pouvoir être nulle qui peut être éventuellement bidirectionnelle (collection d’inscriptions dansJoueur). - 
        
D’une relation
ManyToOneavecClubqui ne doit pas pouvoir être nulle qui peut être éventuellement bidirectionnelle (collection d’inscriptions dansClub). 
 - 
        
 - 
    
On ajoute une contrainte d’unicité pour la base de données sur le couple de clés étrangères référençant
JoueuretClubgrâce à l’attribut#[ORM\UniqueConstraint]. - 
    
On ajoute une contrainte d’unicité pour l’application sur le couple d’attributs
joueuretclubgrâce à l’attribut#[ORM\UniqueEntity]. - 
    
On ajoute des assertions
NotBlanketNotNullsurjoueuretclub. - 
    
On met à jour la structure de la base de données avec doctrine.
 
Si l’entité évolue (porteuse de données, ternaire, etc…) il suffira de rajouter de nouveaux attributs et/ou d’adapter les contraintes d’unicité.
Tout cela donnera, du côté de l’application :
#[ORM\Entity(repositoryClass: InscriptionRepository::class)]
#[UniqueEntity(fields: ['joueur', 'club'], message: "Un joueur ne peut pas être inscrit plus d'une fois au même club.")]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_JOUEUR_CLUB', fields: ['joueur', 'club'])]
#[ApiResource(
    operations: [
        new Post(),
        new Delete()
    ]
    normalizationContext: ["groups" => ['inscription:read']],
    denormalizationContext: ["groups" => ['inscription:write']],
    validationContext: ["groups" => ['Default', 'inscription:write']]
)]
class Inscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['inscription:read', 'joueur:read', 'club:read'])]
    private ?int $id = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:write'])]
    #[Assert\NotNull(groups: ['inscription:write'])]
    #[Groups(['inscription:read', 'inscription:write', 'club:read'])]
    private ?Joueur $joueur = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:write'])]
    #[Assert\NotNull(groups: ['inscription:write'])]
    #[Groups(['inscription:read', 'inscription:write', 'joueur:read'])]
    private ?Club $club = null;
}
#[ORM\Entity(repositoryClass: JoueurRepository::class)]
#[ApiResource(
    normalizationContext: ["groups" => ['joueur:read', 'ville:read', 'resultat:read']],
    denormalizationContext: ["groups" => ['joueur:write', 'resultat:write']],
)]
class Joueur
{
#[ORM\Id]
    ...
    /**
     * @var Collection<int, Inscription>
     */
    #[ORM\OneToMany(targetEntity: Inscription::class, mappedBy: 'joueur', cascade: ['persist'], orphanRemoval: true)]
    #[Groups(['joueur:read'])]
    private Collection $inscriptions;
}
#[ORM\Entity(repositoryClass: ClubRepository::class)]
#[ApiResource(
    normalizationContext: ["groups" => ['club:read']]
)]
class Club
{
    ...
    /**
     * @var Collection<int, Inscription>
     */
    #[ORM\OneToMany(targetEntity: Inscription::class, mappedBy: 'club', cascade: ['persist'], orphanRemoval: true)]
    #[Groups(['club:read'])]
    private Collection $inscriptions;
}
Avec cette implémentation, il est possible de :
- Créer des inscriptions d’un joueur à un club.
 - Désinscrire un joueur d’un club.
 - Récupérer les inscriptions d’un joueur (en lisant les données du joueur) ou d’un club (en lisant les données du club).
 
POST /api/inscriptions
{
    "joueur": "/api/joueurs/2",
    "club": "/api/clubs/3"
}
DELETE /api/inscriptions/5
Avantages de cette solution
- 
    
De manière générale, l’utilisation de clés primaires “techniques” simples (et donc de classes coordinatrices où la contrainte d’unicité est explicitement gérée) à la place d’associations “plusieurs-plusieurs” est recommandée par une partie des développeurs (débat surogate primary keys vs natural primary keys).
 - 
    
Cette solution est la plus flexible en pratique, et facilement prise en charge par Api Platform, sans causer de soucis de performances avec Doctrine.
 - 
    
Elle s’adapte bien si la ressource devient plus complexe.
 
Inconvénients
- 
    
Ne respecte pas le modèle E/A et le schéma relationnel d’origine, car on transforme les associations en entités.
 - 
    
Nécessite de gérer les contraintes d’unicité (et not null) explicitement.
 - 
    
Complique légèrement les relations entre les entités, et nécessite l’utilisation d’une clé primaire “technique” (surrogate).
 
Sérialisation
Dans notre exemple, si on lit les données d’un joueur ou d’un club, seuls les IRIs des clubs auxquels est inscrit le joueur seront affichées (et pareil pour les membres d’un club). Si on souhaite afficher le détail des clubs (ou des membres), il faut utiliser les groupes de sérialisation adéquatement.
Il faut faire très attention de ne pas boucler (joueur affiche club qui affiche ses membres, qui affiche les clubs des membres, qui affichent leurs membres…). Pour cela, il faut éviter les relations circulaires.
Par exemple, remanions nos entités pour afficher les détails des clubs où est inscrit un joueur et inversement :
...
class Inscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['inscription:read', 'inscription:joueur:read', 'inscription:club:read'])]
    private ?int $id = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:write', 'joueur:write'])]
    #[Assert\NotNull(groups: ['inscription:write', 'joueur:write'])]
    #[Groups(['inscription:read', 'inscription:write', 'inscription:joueur:read'])]
    private ?Joueur $joueur = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:write'])]
    #[Assert\NotNull(groups: ['inscription:write'])]
    #[Groups(['inscription:read', 'inscription:write', 'inscription:club:read'])]
    private ?Club $club = null;
}
#[ORM\Entity(repositoryClass: JoueurRepository::class)]
#[ApiResource(
    normalizationContext: ["groups" => ['joueur:read', 'ville:read', 'resultat:read', 'inscription:club:read']],
    ...
)]
class Joueur
{
#[ORM\Id]
    
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['joueur:read', 'inscription:joueur:read'])]
    private ?int $id = null;
    #[ORM\Column(length: 255)]
    #[Groups(['joueur:read', 'joueur:write', 'inscription:joueur:read'])]
    private ?string $nom = null;
    #[ORM\Column(length: 255)]
    #[Groups(['joueur:read', 'joueur:write', 'inscription:joueur:read'])]
    private ?string $prenom = null;
    ...
    /**
     * @var Collection<int, Inscription>
     */
    #[ORM\OneToMany(targetEntity: Inscription::class, mappedBy: 'joueur', cascade: ['persist'], orphanRemoval: true)]
    #[Groups(['joueur:read'])]
    private Collection $inscriptions;
}
#[ORM\Entity(repositoryClass: ClubRepository::class)]
#[ApiResource(
    normalizationContext: ["groups" => ['club:read', 'inscription:joueur:read']],
    denormalizationContext: ["groups" => ['club:write']]
    ...
)]
class Club
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['club:read', 'inscription:club:read'])]
    private ?int $id = null;
    #[ORM\Column(length: 255)]
    #[Groups(['club:read', 'club:write', 'inscription:club:read'])]
    private ?string $nom = null;
    /**
     * @var Collection<int, Inscription>
     */
    #[ORM\OneToMany(targetEntity: Inscription::class, mappedBy: 'club', cascade: ['persist'], orphanRemoval: true)]
    #[Groups(['club:read'])]
    private Collection $inscriptions;
}
Ici, afin d’éviter de boucler avec des relations circulaires, nous avons inclus de nouveaux groupes 
(inscription:joueur:read et inscription:club:read) qui excluent les collections d’inscriptions (de Joueurs et Club).
Ce qui donne, par exemple :
GET /api/joueurs/5
{
    "id" : 5,
    "nom": "Terrieur",
    "prenom": "Alain",
    "ville": {
        "nom" : "Toulouse",
        "codePostal": 31000
    },
    "inscriptions": [
        {
            "club" : {
                "id": 2,
                "nom" : "Hello Club"
            }
        },
        {
            "club" : {
                "id": 7,
                "nom" : "UML Club"
            }
        }
    ]
}
Comme vous pouvez le constater, quand il y a beaucoup de relations bidirectionnelles, le processus de sérialisation devient plus complexe. Il est toujours possible de trouver une solution, mais il faudra bien réfléchir à l’affectation des groupes pour de pas avoir de comportement inattendu.
Routes avec sous-ressources
Comme nous l’avons fait précédemment, il serait souhaitable d’avoir les routes suivantes :
/api/joueurs/{idJoueur}/inscriptions/: pour récupérer les inscriptions d’un joueur, ou en créer de nouvelles./api/joueurs/{idJoueur}/inscriptions/{idInscription}: pour récupérer les détails d’une inscription d’un joueur, ou la supprimer./api/clubs/{idClub}/inscriptions/: pour récupérer les inscriptions d’un club, ou en créer de nouvelles./api/clubs/{idClub}/inscriptions/{idInscription}: pour récupérer les détails d’une inscription d’un club, ou la supprimer.
On pourrait soit garder la route de base /api/inscriptions et ajouter les nouvelles routes ou alors seulement garder les nouvelles routes.
Nous allons choisir la deuxième option.
#[ApiResource(
    uriTemplate: '/joueurs/{idJoueur}/inscriptions',
    operations: [
        new Post(provider: CreateProvider::class),
        new GetCollection(),
    ],
    uriVariables: [
        'idJoueur' => new Link(
            toProperty: 'joueur',
            fromClass: Joueur::class,
        )
    ],
    normalizationContext: ["groups" => ["inscription:club:read"]],
    denormalizationContext: ["groups" => ['inscription:club:write']],
    validationContext: ["groups" => ["Default", "inscription:club:write"]],
)]
#[ApiResource(
    uriTemplate: '/joueurs/{idJoueur}/inscriptions/{idInscription}',
    operations: [
        new Get(),
        new Delete(),
    ],
    uriVariables: [
        'idJoueur' => new Link(
            toProperty: 'joueur',
            fromClass: Joueur::class,
        ),
        'idInscription' => new Link(
            fromClass: Inscription::class,
        )
    ],
    normalizationContext: ["groups" => ["inscription:club:read"]],
)]
#[ApiResource(
    uriTemplate: '/clubs/{idClub}/inscriptions',
    operations: [
        new Post(provider: CreateProvider::class),
        new GetCollection(),
    ],
    uriVariables: [
        'idClub' => new Link(
            toProperty: 'club',
            fromClass: Club::class,
        )
    ],
    normalizationContext: ["groups" => ['inscription:joueur:read']],
    denormalizationContext: ["groups" => ['inscription:joueur:write']],
    validationContext: ["groups" => ["Default", "inscription:joueur:write"]]
)]
#[ApiResource(
    uriTemplate: '/clubs/{idClub}/inscriptions/{idInscription}',
    operations: [
        new Get(),
        new Delete(),
    ],
    uriVariables: [
        'idClub' => new Link(
            toProperty: 'club',
            fromClass: Club::class,
        ),
        'idInscription' => new Link(
            fromClass: Inscription::class,
        )
    ],
    normalizationContext: ["groups" => ['inscription:joueur:read']]
)]
class Inscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['inscription:joueur:read', 'inscription:club:read'])]
    private ?int $id = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:joueur:write'])]
    #[Assert\NotNull(groups: ['inscription:joueur:write'])]
    #[Groups(['inscription:joueur:write', 'inscription:joueur:read'])]
    private ?Joueur $joueur = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:club:write'])]
    #[Assert\NotNull(groups: ['inscription:club:write'])]
    #[Groups(['inscription:club:write', 'inscription:club:read'])]
    private ?Club $club = null;
}
Il est important de noter que notre exemple est assez complexe au niveau des groupes de validation et de sérialisation, car nous avons voulu faire en sorte de pratiquement tout avoir avec (en plus) des relations bidirectionnelles :
- Lister les clubs dans les données du joueur et inversement.
 - Avoir des routes spécifiques pour lire, créer ou supprimer des inscriptions par rapport à un joueur ou à un club.
 
Dans un cas concret, vous pourriez vouloir seulement un sous-ensemble de ces possibilités.
Routes composées des identifiants des entités
On pourrait reprendre le style de route que nous utilisions dans la première solution et les appliquer avec notre implémentation actuelle :
PUT /joueurs/{idJoueur}/clubs/{idClub}: pour ajouter un joueur à un club.DELETE /joueurs/{idJoueur}/clubs/{idClub}: pour retirer un joueur d’un club.GET /joueurs/{idJoueur}/clubs/{idClub}: pour vérifier si un joueur est inscrit à un certain club.
Et les routes inverses (/clubs/{idClub}/clubs/{idJoueur}).
Ce qui nous permettrait de compléter notre ensemble de route.
Cependant, tout cela demande aussi de coder un StateProvider et un StateProcessor dédiés :
namespace App\State;
class InscriptionProvider implements ProviderInterface
{
    public function __construct(
        private InscriptionRepository $inscriptionRepository,
    )
    {
    }
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
       return $this->inscriptionRepository->findOneBy(["joueur" => $uriVariables["idJoueur"], "club" => $uriVariables["idClub"]]);
    }
}
namespace App\State;
class InscriptionProcessor implements ProcessorInterface
{
    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private ProcessorInterface $persistProcessor,
        private JoueurRepository $joueurRepository,
        private ClubRepository $clubRepository,
    )
    {}
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        $joueur = $this->joueurRepository->find($uriVariables["idJoueur"]);
        if(!$joueur) {
            throw new NotFoundHttpException("Joueur inexistant.");
        }
        $club = $this->clubRepository->find($uriVariables["idClub"]);
        if(!$club) {
            throw new NotFoundHttpException("Club inexistant.");
        }
        if(!$data) {
            $data = new Inscription();
        }
        $data->setJoueur($joueur);
        $data->setClub($club);
        return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
    }
}
Et enfin, on définit les routes dans Inscription :
#[ApiResource(
    uriTemplate: '/joueurs/{idJoueur}/clubs/{idClub}',
    operations: [
        new Get(),
        new Delete(),
        new Put(deserialize: false, processor: InscriptionProcessor::class, allowCreate: true),
    ],
    uriVariables: [
        'idJoueur' => new Link(
            toProperty: 'joueur',
            fromClass: Joueur::class,
        ),
        'idClub' => new Link(
            toProperty: 'club',
            fromClass: Club::class,
        )
    ],
    normalizationContext: ["groups" => ['inscription:club:read']],
    provider: InscriptionProvider::class
)]
#[ApiResource(
    uriTemplate: '/clubs/{idClub}/joueurs/{idJoueur}',
    operations: [
        new Get(),
        new Delete(),
        new Put(deserialize: false, processor: InscriptionProcessor::class, allowCreate: true),
    ],
    uriVariables: [
        'idJoueur' => new Link(
            toProperty: 'joueur',
            fromClass: Joueur::class,
        ),
        'idClub' => new Link(
            toProperty: 'club',
            fromClass: Club::class,
        )
    ],
    normalizationContext: ["groups" => ['inscription:joueur:read']],
    provider: InscriptionProvider::class
)]
class Inscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['inscription:joueur:read', 'inscription:club:read'])]
    private ?int $id = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:joueur:write'])]
    #[Assert\NotNull(groups: ['inscription:joueur:write'])]
    #[Groups(['inscription:joueur:write', 'inscription:joueur:read'])]
    private ?Joueur $joueur = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:club:write'])]
    #[Assert\NotNull(groups: ['inscription:club:write'])]
    #[Groups(['inscription:club:write', 'inscription:club:read'])]
    private ?Club $club = null;
}
L’idée ne serait pas forcément de remplacer les routes précédentes, mais elles pourraient éventuellement remplacer (ou compléter) celles qui permettent de créer, lire et supprimer une inscription. Pour la lecture et la suppression, on a uniquement besoin de connaître les identifiants du joueur et du club concerné, sans avoir besoin de connaître l’identifiant “technique” de l’entité inscription.
Note à part : en codant des StateProviders adéquats, on pourrait coder la logique des routes suivantes api/joueurs/{id}/clubs (dans Club) et api/clubs/{id}/joueurs (dans Joueur).
Utilisation d’une entité avec une clé composite
Une dernière solution (que nous allons présenter brièvement) serait d’utiliser une entité avec une clé composite liée à la fois à un joueur et un club :
En base de données, cela génèrera une table Inscription(#idJoueur, #idClub) comme celle présentée dans le schéma relationnel initial.
Notre entité Inscription à deux relations ManyToOne : une vers le Joueur et l’autre vers Club.
Du côté de Joueur et Club, on a alors des collections d’entités Inscription (on supprime les relations ManyToOne 
entre Club et Joueur).
On peut créer, supprimer ou mettre à jour des entités Inscription en utilisant les identifiants du joueur et du club concernés.
Cette méthode respecte la modélisation initiale du modèle E/A et la contrainte d’unicité est naturellement gérée au travers de la clé (pas deux fois la même inscription d’un joueur à un même club).
Dans l’idée, pour mettre en place cette solution, il faudrait suivre ces étapes :
- 
    
On n’associe pas
JoueuretClubavec uneManyToMany. - 
    
À la place, on crée une nouvelle entité
Inscription(et son repository) composée :- 
        
D’une relation
ManyToOneavecJoueurqui ne doit pas pouvoir être nulle qui peut être éventuellement bidirectionnelle (collection d’inscriptions dansJoueur). - 
        
D’une relation
ManyToOneavecClubqui ne doit pas pouvoir être nulle qui peut être éventuellement bidirectionnelle (collection d’inscriptions dansClub). - 
        
D’une clé composite composée du
joueuret duclub. 
 - 
        
 - 
    
On ajoute des assertions
NotBlanketNotNullsurjoueuretclub. - 
    
On met à jour la structure de la base de données avec doctrine.
 
Globalement, notre classe Inscription ressemblerait à ceci :
#[ORM\Entity(repositoryClass: InscriptionRepository::class)]
#[ApiResource]
class Inscription
{
    #[Id, ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:joueur:write'])]
    #[Assert\NotNull(groups: ['inscription:joueur:write'])]
    private ?Joueur $joueur = null;
    #[Id, ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:club:write'])]
    #[Assert\NotNull(groups: ['inscription:club:write'])]
    private ?Club $club = null;
}
Il faudrait ensuite coder les différentes routes en utilisant un StateProvider et des StateProcessor customisés.
Par exemple :
#[ORM\Entity(repositoryClass: InscriptionRepository::class)]
#[ApiResource(
    uriTemplate: '/joueurs/{idJoueur}/clubs/{idClub}',
    operations: [
        new Put(deserialize: false, processor: InscriptionProcessor::class, allowCreate: true),
        new Get(),
        new Delete(),
    ],
    uriVariables: [
        'idJoueur' => new Link(
            fromClass: Joueur::class
        ),
        'idClub' => new Link(
            fromClass: Club::class
        ),
    ],
    provider: InscriptionProvider::class
)]
class Inscription
{
    #[Id, ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:joueur:write'])]
    #[Assert\NotNull(groups: ['inscription:joueur:write'])]
    private ?Joueur $joueur = null;
    #[Id, ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:club:write'])]
    #[Assert\NotNull(groups: ['inscription:club:write'])]
    private ?Club $club = null;
}
class InscriptionProvider implements ProviderInterface
{
    public function __construct(private InscriptionRepository $repository)
    {
    }
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        return $this->repository->find(["joueur" => $uriVariables["idJoueur"], "club" => $uriVariables["idClub"]]);
    }
}
class InscriptionProcessor implements ProcessorInterface
{
    public function __construct(
        private JoueurRepository $joueurRepository,
        private ClubRepository $clubRepository,
        private EntityManagerInterface $entityManager
    )
    {}
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        if(!$data) {
            $joueur = $this->joueurRepository->find($uriVariables["idJoueur"]);
            if(!$joueur) {
                throw new NotFoundHttpException("Joueur inexistant.");
            }
            $club = $this->clubRepository->find($uriVariables["idClub"]);
            if(!$club) {
                throw new NotFoundHttpException("Club inexistant.");
            }
            $data = new Inscription();
            $data->setJoueur($joueur);
            $data->setClub($club);
            $this->entityManager->persist($data);
            $this->entityManager->flush();
        }
       
        return $data;
    }
}
- 
    
On pourra ensuite utiliser la route :
/api/joueurs/{idJoueur}/clubs/{idClub}pour créer, récupérer ou supprimer une inscription entre un joueur et un club (et inversement). - 
    
On pourrait aussi ajouter les routes
/api/joueurs/{idJoueur}/inscriptionset/api/clubs/{idClub}/inscriptionsou même/api/joueurs/{idJoueur}/clubs/et/api/clubs/{idClub}/joueurs/. 
Avantages de cette solution
- 
    
Plus respectueuse du modèle E/A et du schéma relationnel initial.
 - 
    
Ne nécessite pas d’identifiant “technique” supplémentaire.
 - 
    
S’adapte à toutes les situations.
 
Inconvénients
- 
    
Dans le pratique, même si Doctrine gère les clés composites, il est déconseillé de les utiliser par soucis de performance.
 - 
    
La mise en place d’une telle méthode peut être difficile au travers d’API Platform.
 - 
    
Comme il n’y a pas d’identifiants “technique” (comme avec les inscriptions), l’architecture de la route peut être compliquée à gérer et à faire évoluer.
 - 
    
Architecture de route compliquée s’il y a plus de deux entités (ternaires, etc…).
 
Évolution de l’association
Une association binaire entre deux classes peut éventuellement évoluer :
- 
    
Si on ajoute des données sur l’association (elle devient alors porteuse).
 - 
    
Si de nouvelles entités participent à l’association (elle devient une ternaire ou plus).
 
Avec les deux dernières solutions présentées (mais principalement celle avec la classe coordinatrice), il est facile de faire évoluer la ressource :
- Porteuse : on ajoute de nouveaux attributs dans l’entité.
 - Nouvelle entité (transformation de l’association en n-aire avec n > 2) : on ajoute de nouvelles relations dans l’entité.
 
Imaginons qu’on souhaite connaître la date d’inscription d’un joueur à un club : il suffit d’ajouter 
une propriété $dateInscription à notre entité Inscription :
class Inscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['inscription:joueur:read', 'inscription:club:read'])]
    private ?int $id = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:joueur:write'])]
    #[Assert\NotNull(groups: ['inscription:joueur:write'])]
    #[Groups(['inscription:joueur:write', 'inscription:joueur:read'])]
    private ?Joueur $joueur = null;
    #[ORM\ManyToOne(inversedBy: 'inscriptions')]
    #[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
    #[Assert\NotBlank(groups: ['inscription:club:write'])]
    #[Assert\NotNull(groups: ['inscription:club:write'])]
    #[Groups(['inscription:club:write', 'inscription:club:read'])]
    private ?Club $club = null;
    #[ORM\Column(type: Types::DATETIME_MUTABLE)]
    private ?\DateTimeInterface $dateInscription = null;
}
On pourra soit la fournir dans le payload, soit la générer automatiquement (dans cas, il faudrait interdire l’écriture sur cette propriété).
Conclusion
À travers les deux parties de ce complément de TD, vous avez appris à :
- 
    
Mieux gérer les différents types de relations.
 - 
    
Implémenter des associations plusieurs-plusieurs binaires (ou plus) avec différentes solutions.
 - 
    
Utiliser plus amplement le système de sous-ressource.
 - 
    
Utiliser des StateProvider.
 - 
    
Gérer la sérialisation entre différentes classes de manière plus poussée.
 
Attention, comme mentionné quelques fois dans ce complément, nous n’avons pas (beaucoup) géré la sécurité des actions.
Dans un contexte réel, il faudrait utiliser le paramètre security sur les différentes routes et opérations (et coupler cela avec des Voters) afin de vérifier que l’utilisateur a bien le droit de faire une action donnée, en fonction des données la ressource créée ou modifiée.
Il faudrait aussi gérer les groupes de dénormalisaiton pour définir quand une propriété peut être modifiée ou non.
Par exemple :
- Un joueur ne doit pas pouvoir s’attribuer le casier d’un autre joueur.
 - Un joueur ne peut pas s’attribuer le résultat d’un autre joueur.
 - Dans une inscription, on ne doit pas pouvoir changer le joueur ou le club concerné.
 - Un joueur ne doit pas pouvoir modifier sa liste d’inscriptions (à des clubs) notamment en précisant une inscription existante à laquelle il n’est pas liée…
 
Bref, il est important de garder tout cela en tête !