288 votes

Doctrine2 : Meilleure façon de gérer le many-to-many avec des colonnes supplémentaires dans la table de référence

Je me demande quel est le meilleur moyen, le plus propre et le plus simple de travailler avec des relations many-to-many dans Doctrine2.

Supposons que nous ayons un album comme Le maître des marionnettes par Metallica avec plusieurs titres. Mais veuillez noter qu'une piste peut apparaître dans plus d'un album, par exemple Batterie par Metallica fait - trois albums comportent ce titre.

J'ai donc besoin d'une relation many-to-many entre les albums et les pistes, en utilisant une troisième table avec quelques colonnes supplémentaires (comme la position de la piste dans l'album spécifié). En fait, je dois utiliser, comme le suggère la documentation de Doctrine, une double relation un-à-plusieurs pour réaliser cette fonctionnalité.

/** @Entity() */
class Album {
    /** @Id @Column(type="integer") */
    protected $id;

    /** @Column() */
    protected $title;

    /** @OneToMany(targetEntity="AlbumTrackReference", mappedBy="album") */
    protected $tracklist;

    public function __construct() {
        $this->tracklist = new \Doctrine\Common\Collections\ArrayCollection();
    }

    public function getTitle() {
        return $this->title;
    }

    public function getTracklist() {
        return $this->tracklist->toArray();
    }
}

/** @Entity() */
class Track {
    /** @Id @Column(type="integer") */
    protected $id;

    /** @Column() */
    protected $title;

    /** @Column(type="time") */
    protected $duration;

    /** @OneToMany(targetEntity="AlbumTrackReference", mappedBy="track") */
    protected $albumsFeaturingThisTrack; // btw: any idea how to name this relation? :)

    public function getTitle() {
        return $this->title;
    }

    public function getDuration() {
        return $this->duration;
    }
}

/** @Entity() */
class AlbumTrackReference {
    /** @Id @Column(type="integer") */
    protected $id;

    /** @ManyToOne(targetEntity="Album", inversedBy="tracklist") */
    protected $album;

    /** @ManyToOne(targetEntity="Track", inversedBy="albumsFeaturingThisTrack") */
    protected $track;

    /** @Column(type="integer") */
    protected $position;

    /** @Column(type="boolean") */
    protected $isPromoted;

    public function getPosition() {
        return $this->position;
    }

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

    public function getAlbum() {
        return $this->album;
    }

    public function getTrack() {
        return $this->track;
    }
}

Exemple de données :

             Album
+----+--------------------------+
| id | title                    |
+----+--------------------------+
|  1 | Master of Puppets        |
|  2 | The Metallica Collection |
+----+--------------------------+

               Track
+----+----------------------+----------+
| id | title                | duration |
+----+----------------------+----------+
|  1 | Battery              | 00:05:13 |
|  2 | Nothing Else Matters | 00:06:29 |
|  3 | Damage Inc.          | 00:05:33 |
+----+----------------------+----------+

              AlbumTrackReference
+----+----------+----------+----------+------------+
| id | album_id | track_id | position | isPromoted |
+----+----------+----------+----------+------------+
|  1 |        1 |        2 |        2 |          1 |
|  2 |        1 |        3 |        1 |          0 |
|  3 |        1 |        1 |        3 |          0 |
|  4 |        2 |        2 |        1 |          0 |
+----+----------+----------+----------+------------+

Je peux maintenant afficher une liste des albums et des pistes qui leur sont associées :

$dql = '
    SELECT   a, tl, t
    FROM     Entity\Album a
    JOIN     a.tracklist tl
    JOIN     tl.track t
    ORDER BY tl.position ASC
';

$albums = $em->createQuery($dql)->getResult();

foreach ($albums as $album) {
    echo $album->getTitle() . PHP_EOL;

    foreach ($album->getTracklist() as $track) {
        echo sprintf("\t#%d - %-20s (%s) %s\n", 
            $track->getPosition(),
            $track->getTrack()->getTitle(),
            $track->getTrack()->getDuration()->format('H:i:s'),
            $track->isPromoted() ? ' - PROMOTED!' : ''
        );
    }   
}

Les résultats sont ceux que j'attendais, c'est-à-dire une liste d'albums avec leurs pistes dans l'ordre approprié et les albums promus étant marqués comme promus.

The Metallica Collection
    #1 - Nothing Else Matters (00:06:29) 
Master of Puppets
    #1 - Damage Inc.          (00:05:33) 
    #2 - Nothing Else Matters (00:06:29)  - PROMOTED!
    #3 - Battery              (00:05:13) 

Alors, qu'est-ce qui ne va pas ?

Ce code démontre ce qui ne va pas :

foreach ($album->getTracklist() as $track) {
    echo $track->getTrack()->getTitle();
}

Album::getTracklist() renvoie un tableau de AlbumTrackReference au lieu de Track objets. Je ne peux pas créer de méthodes de proxy car que faire si les deux, Album y Track aurait getTitle() méthode ? Je pourrais faire un traitement supplémentaire dans Album::getTracklist() mais quelle est la manière la plus simple de le faire ? Suis-je obligé d'écrire quelque chose comme ça ?

public function getTracklist() {
    $tracklist = array();

    foreach ($this->tracklist as $key => $trackReference) {
        $tracklist[$key] = $trackReference->getTrack();

        $tracklist[$key]->setPosition($trackReference->getPosition());
        $tracklist[$key]->setPromoted($trackReference->isPromoted());
    }

    return $tracklist;
}

// And some extra getters/setters in Track class

EDITAR

@beberlei a suggéré d'utiliser des méthodes de proxy :

class AlbumTrackReference {
    public function getTitle() {
        return $this->getTrack()->getTitle()
    }
}

Ce serait une bonne idée, mais j'utilise cet "objet de référence" des deux côtés : $album->getTracklist()[12]->getTitle() y $track->getAlbums()[1]->getTitle() donc getTitle() doit renvoyer des données différentes en fonction du contexte d'invocation.

Je devrais faire quelque chose comme :

 getTracklist() {
     foreach ($this->tracklist as $trackRef) { $trackRef->setContext($this); }
 }

 // ....

 getAlbums() {
     foreach ($this->tracklist as $trackRef) { $trackRef->setContext($this); }
 }

 // ...

 AlbumTrackRef::getTitle() {
      return $this->{$this->context}->getTitle();
 }

Et ce n'est pas une façon très propre.

2 votes

Comment gérez-vous la référence AlbumTrackReference ? Par exemple $album->addTrack() ou $album->removeTrack() ?

0 votes

Je n'ai pas compris votre commentaire sur le contexte. A mon avis, les données ne dépendent pas du contexte. A propos de $album->getTracklist()[12] est AlbumTrackRef donc $album->getTracklist()[12]->getTitle() retournera toujours le titre de la piste (si vous utilisez la méthode proxy). Alors que $track->getAlbums()[1] es Album donc $track->getAlbums()[1]->getTitle() retournera toujours le titre de l'album.

0 votes

Une autre idée consiste à utiliser sur AlbumTrackReference deux méthodes de procuration, getTrackTitle() y getAlbumTitle .

160voto

FMaz008 Points 3389

J'ai ouvert une question similaire dans la liste de diffusion des utilisateurs de Doctrine et j'ai obtenu une réponse très simple ;

considérez la relation many to many comme une entité en soi, et vous réalisez alors que vous avez 3 objets, liés entre eux par une relation one-to-many et many-to-one.

http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868

Une fois qu'une relation a des données, ce n'est plus une relation !

0 votes

Quelqu'un sait-il comment obtenir l'outil de ligne de commande de doctrine pour générer cette nouvelle entité en tant que fichier de schéma yml ? Cette commande : app/console doctrine:mapping:import AppBundle yml générer encore une relation manyToMany pour les deux tables originales et ignorer simplement la troisième table au lieu de la considérer comme une entité. :/

0 votes

Quelle est la différence entre foreach ($album->getTracklist() as $track) { echo $track->getTrack()->getTitle(); } fourni par @Crozin et consider the relationship as an entity ? Je pense que ce qu'il veut demander, c'est comment sauter l'entité relationnelle et récupérer le titre d'un morceau en utilisant la fonction foreach ($album->getTracklist() as $track) { echo $track->getTitle(); }

6 votes

"Une fois qu'une relation a des données, ce n'est plus une relation" C'était vraiment instructif. Je n'arrivais pas à penser à une relation du point de vue d'une entité !

17voto

beberlei Points 2645

À partir de $album->getTrackList(), vous obtiendrez toujours les entités "AlbumTrackReference" en retour, alors pourquoi ne pas ajouter des méthodes à partir de la piste et du proxy ?

class AlbumTrackReference
{
    public function getTitle()
    {
        return $this->getTrack()->getTitle();
    }

    public function getDuration()
    {
        return $this->getTrack()->getDuration();
    }
}

De cette façon, votre boucle se simplifie considérablement, ainsi que tout autre code lié au bouclage des pistes d'un album, puisque toutes les méthodes sont simplement proxiées dans AlbumTrakcReference :

foreach ($album->getTracklist() as $track) {
    echo sprintf("\t#%d - %-20s (%s) %s\n", 
        $track->getPosition(),
        $track->getTitle(),
        $track->getDuration()->format('H:i:s'),
        $track->isPromoted() ? ' - PROMOTED!' : ''
    );
}

Btw Vous devriez renommer la AlbumTrackReference (par exemple "AlbumTrack"). Il est clair que ce n'est pas seulement une référence, mais qu'elle contient une logique supplémentaire. Puisqu'il y a probablement aussi des pistes qui ne sont pas liées à un album mais juste disponibles par le biais d'un CD promo ou autre, cela permet une séparation plus nette.

1 votes

Les méthodes proxy ne résolvent pas le problème à 100% (vérifiez mon édition). Btw You should rename the AlbumT(...) - bon point

3 votes

Pourquoi ne pas avoir deux méthodes ? getAlbumTitle() et getTrackTitle() sur l'objet AlbumTrackReference ? Les deux ont un proxy vers leurs sous-objets respectifs.

0 votes

L'objectif est le plus naturel l'API objet. $album->getTracklist()[1]->getTrackTitle() est aussi bon/mauvais que $album->getTracklist()[1]->getTrack()->getTitle() . Cependant, il semble que je devrais avoir deux classes différentes : une pour les références album->piste et une autre pour les références piste->albums - et c'est trop difficile à mettre en œuvre. C'est probablement la meilleure solution pour l'instant...

13voto

Wilt Points 867

Rien ne vaut un bel exemple

Pour les personnes qui recherchent un exemple de codage propre d'une association un-à-many/many-to-one entre les trois classes participantes pour stocker des attributs supplémentaires dans la relation, consultez ce site :

bel exemple d'associations one-to-many/many-to-one entre les 3 classes participantes

Pensez à vos clés primaires

Pensez également à votre clé primaire. Vous pouvez souvent utiliser des clés composites pour des relations comme celle-ci. Doctrine le supporte nativement. Vous pouvez transformer vos entités référencées en ids. Consultez la documentation sur les clés composites ici

10voto

Ocramius Points 10275

Je pense que je suivrais la suggestion de @beberlei d'utiliser des méthodes proxy. Ce que vous pouvez faire pour simplifier ce processus est de définir deux interfaces :

interface AlbumInterface {
    public function getAlbumTitle();
    public function getTracklist();
}

interface TrackInterface {
    public function getTrackTitle();
    public function getTrackDuration();
}

Ensuite, vos deux Album et votre Track peut les mettre en œuvre, tandis que le AlbumTrackReference peut toujours mettre en œuvre les deux, comme suit :

class Album implements AlbumInterface {
    // implementation
}

class Track implements TrackInterface {
    // implementation
}

/** @Entity whatever */
class AlbumTrackReference implements AlbumInterface, TrackInterface
{
    public function getTrackTitle()
    {
        return $this->track->getTrackTitle();
    }

    public function getTrackDuration()
    {
        return $this->track->getTrackDuration();
    }

    public function getAlbumTitle()
    {
        return $this->album->getAlbumTitle();
    }

    public function getTrackList()
    {
        return $this->album->getTrackList();
    }
}

De cette façon, en supprimant la logique qui fait directement référence à une Track ou un Album et en le remplaçant simplement pour qu'il utilise un fichier TrackInterface o AlbumInterface vous pouvez utiliser votre AlbumTrackReference dans tous les cas possibles. Ce dont vous aurez besoin, c'est de différencier un peu les méthodes entre les interfaces.

Cela ne différencie pas la logique DQL ni celle du référentiel, mais vos services ignoreront simplement le fait que vous passez un fichier Album ou un AlbumTrackReference ou un Track ou un AlbumTrackReference parce que vous avez tout caché derrière une interface :)

J'espère que cela vous aidera !

7voto

jsuggs Points 1038

Tout d'abord, je suis en grande partie d'accord avec beberlei sur ses suggestions. Cependant, vous êtes peut-être en train de vous attirer dans un piège. Votre domaine semble considérer que le titre est la clé naturelle d'un morceau, ce qui est probablement le cas pour 99% des scénarios que vous rencontrez. Cependant, que se passe-t-il si Batterie en Le maître des marionnettes est une version différente (longueur différente, live, acoustique, remix, remastérisé, etc.) de la version sur La collection Metallica .

Selon la façon dont vous voulez gérer (ou ignorer) ce cas, vous pouvez soit suivre la voie suggérée par Beberlei, soit vous contenter de la logique supplémentaire que vous proposez dans Album::getTracklist(). Personnellement, je pense que la logique supplémentaire est justifiée pour garder votre API propre, mais les deux ont leurs mérites.

Si vous souhaitez tenir compte de mon cas d'utilisation, vous pourriez faire en sorte que les pistes contiennent un auto-référencement OneToMany vers d'autres pistes, éventuellement $similarTracks. Dans ce cas, il y aurait deux entités pour la piste Batterie un pour La collection Metallica et un pour Le maître des marionnettes . Ainsi, chaque entité Track similaire contiendrait une référence à l'autre. De plus, cela permettrait de se débarrasser de la classe AlbumTrackReference actuelle et d'éliminer votre "problème" actuel. Je suis d'accord pour dire qu'il s'agit simplement de déplacer la complexité vers un point différent, mais cela permet de gérer un cas d'utilisation qui n'était pas possible auparavant.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X