Doctrine2: meilleure façon de gérer plusieurs à plusieurs avec des colonnes supplémentaires dans le tableau de référence

282

Je me demande quelle est la meilleure, la plus propre et la plus simple façon de travailler avec des relations plusieurs-à-plusieurs dans Doctrine2.

Supposons que nous ayons un album comme Master of Puppets de Metallica avec plusieurs pistes. Mais veuillez noter le fait qu'une piste peut apparaître dans plus d'un album, comme le fait Battery by Metallica - trois albums présentent cette piste.

Donc, ce dont j'ai besoin, c'est d'une relation plusieurs-à-plusieurs entre les albums et les pistes, en utilisant un troisième tableau 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 atteindre 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;
    }
}

Exemples 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 |
+----+----------+----------+----------+------------+

Maintenant, je peux afficher une liste d'albums et de pistes qui leur sont associés:

$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 ce que j'attends, c'est-à-dire: une liste d'albums avec leurs morceaux dans l'ordre approprié et ceux 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 montre ce qui ne va pas:

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

Album::getTracklist()renvoie un tableau d' AlbumTrackReferenceobjets au lieu d' Trackobjets. Je ne peux pas créer de méthodes proxy car si les deux, Albumet Trackaurait une getTitle()méthode? Je pourrais faire un traitement supplémentaire dans la Album::getTracklist()méthode, mais quelle est la façon 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

ÉDITER

@beberlei a suggéré d'utiliser des méthodes 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()et $track->getAlbums()[1]->getTitle(), donc, la getTitle()méthode devrait renvoyer des données différentes en fonction du contexte de l'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.

Crozin
la source
2
Comment gérez-vous l'AlbumTrackReference? Par exemple $ album-> addTrack () ou $ album-> removeTrack ()?
Daniel
Je n'ai pas compris que vous commentiez le contexte. À mon avis, les données ne dépendent pas du contexte. About $album->getTracklist()[12]is AlbumTrackRefobject, donc $album->getTracklist()[12]->getTitle()retournera toujours le titre de la piste (si vous utilisez la méthode proxy). Bien qu'il $track->getAlbums()[1]soit Albumobjet, il $track->getAlbums()[1]->getTitle()renverra toujours le titre de l'album.
Vinícius Fagundes
Une autre idée consiste à utiliser AlbumTrackReferencedeux méthodes proxy, getTrackTitle()et getAlbumTitle.
Vinícius Fagundes

Réponses:

158

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

considérez la relation plusieurs à plusieurs comme une entité elle-même, puis vous réalisez que vous avez 3 objets, liés entre eux avec une relation un-à-plusieurs et plusieurs-à-un.

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!

FMaz008
la source
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 ymlgénère toujours la relation manyToMany pour les deux tables d'origine et ignore simplement la troisième table au lieu de la considérer comme une entité:/
Stéphane
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'une piste en utilisantforeach ($album->getTracklist() as $track) { echo $track->getTitle(); }
panda
6
"Une fois qu'une relation a des données, ce n'est plus une relation" C'était vraiment instructif. Je ne pouvais tout simplement pas penser à une relation du point de vue d'une entité!
Oignon
Et si la relation était déjà créée et utilisée en plusieurs. Nous avons réalisé que nous avions besoin de champs supplémentaires dans nos nombreux à nombreux, alors nous avons créé une entité différente. Le problème est qu'avec les données existantes et une table existante du même nom, il ne semble pas vouloir être ami. Quelqu'un a-t-il déjà essayé cela?
tylérisme
Pour ceux qui se demandent: créer une entité avec le plusieurs-à-plusieurs (déjà existant) pouvant être joint comme son tableau fonctionne, cependant, les entités détenant le plusieurs-à-plusieurs doivent être adaptées au lieu de un à plusieurs à la nouvelle entité. les interfaces avec l'extérieur (getters / setters pour les anciens plusieurs-à-plusieurs) doivent très probablement être adaptées.
Jakumi
17

De $ album-> getTrackList (), vous récupérerez toujours les entités "AlbumTrackReference", alors qu'en est-il de l'ajout de 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é à la boucle des pistes d'un album, car toutes les méthodes sont simplement mandaté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 devez renommer AlbumTrackReference (par exemple "AlbumTrack"). Ce n'est clairement pas seulement une référence, mais contient une logique supplémentaire. Puisqu'il y a probablement aussi des pistes qui ne sont pas connectées à un album mais simplement disponibles via un cd promo ou quelque chose cela permet également une séparation plus nette.

beberlei
la source
1
Les méthodes proxy ne résolvent pas le problème à 100% (vérifiez ma modification). Btw You should rename the AlbumT(...)- bon point
Crozin
3
Pourquoi n'avez-vous pas deux méthodes? getAlbumTitle () et getTrackTitle () sur l'objet AlbumTrackReference? Les deux procurent leurs sous-objets respectifs.
beberlei
L'objectif est l' API d'objet la plus naturelle . $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 d'album-> pistes et une autre pour les références de pistes-> albums - et c'est trop difficile à mettre en œuvre. C'est probablement la meilleure solution jusqu'à présent ...
Crozin
13

Rien ne vaut un bel exemple

Pour les personnes à la recherche d'un exemple de codage propre d'une association un-à-plusieurs / plusieurs-à-un entre les 3 classes participantes pour stocker des attributs supplémentaires dans la relation, consultez ce site:

bel exemple d'associations un-à-plusieurs / plusieurs-à-un 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. La doctrine le soutient nativement. Vous pouvez transformer vos entités référencées en identifiants. Consultez la documentation sur les clés composites ici

Se flétrir
la source
10

Je pense que j'irais avec la suggestion de @ beberlei d'utiliser des méthodes de proxy. Pour simplifier ce processus, vous pouvez définir deux interfaces:

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

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

Ensuite, votre Albumet votre Trackpouvez les implémenter, tandis que le AlbumTrackReferencepeut toujours implémenter 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 votre logique qui fait directement référence à un Trackou un Album, et en la remplaçant simplement pour qu'elle utilise un TrackInterfaceou AlbumInterface, vous pouvez utiliser votre AlbumTrackReferencedans tous les cas possibles. Ce dont vous aurez besoin est de différencier un peu les méthodes entre les interfaces.

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

J'espère que cela t'aides!

Ocramius
la source
7

Tout d'abord, je suis principalement d'accord avec beberlei sur ses suggestions. Cependant, vous vous concevez peut-être dans un piège. Votre domaine semble considérer le titre comme la clé naturelle d'une piste, ce qui est probablement le cas pour 99% des scénarios que vous rencontrez. Et si Battery on Master of the Puppets est une version différente (longueur différente, live, acoustique, remix, remasterisé, etc.) que la version de The Metallica Collection .

Selon la façon dont vous voulez gérer (ou ignorer) ce cas, vous pouvez soit suivre l'itinéraire suggéré par beberlei, soit simplement utiliser la logique supplémentaire proposée dans Album :: getTracklist (). Personnellement, je pense que la logique supplémentaire est justifiée pour garder votre API propre, mais les deux ont leur mérite.

Si vous souhaitez prendre en compte mon cas d'utilisation, vous pourriez avoir des pistes contenant un OneToMany auto-référencé avec d'autres pistes, éventuellement $ similarTracks. Dans ce cas, il y aurait deux entités pour la piste Battery , une pour The Metallica Collection et une pour Master of the Puppets . Ensuite, chaque entité Piste similaire contiendrait une référence l'une à l'autre. En outre, cela éliminerait la classe AlbumTrackReference actuelle et éliminerait votre "problème" actuel. Je suis d'accord qu'il déplace simplement la complexité vers un point différent, mais il est capable de gérer un cas d'utilisation qu'il n'était pas en mesure auparavant.

jsuggs
la source
6

Vous demandez le "meilleur moyen" mais il n'y a pas de meilleur moyen. Il existe de nombreuses façons et vous en avez déjà découvert certaines. La façon dont vous voulez gérer et / ou encapsuler la gestion des associations lorsque vous utilisez des classes d'association dépend entièrement de vous et de votre domaine concret, personne ne peut vous montrer la "meilleure façon", je le crains.

En dehors de cela, la question pourrait être beaucoup simplifiée en supprimant la doctrine et les bases de données relationnelles de l'équation. L'essence de votre question se résume à une question sur la façon de traiter les classes d'association dans la POO ordinaire.

romanb
la source
6

J'obtenais un conflit avec une table de jointure définie dans une annotation de classe d'association (avec des champs personnalisés supplémentaires) et une table de jointure définie dans une annotation plusieurs à plusieurs.

Les définitions de mappage dans deux entités avec une relation directe plusieurs-à-plusieurs semblaient entraîner la création automatique de la table de jointure à l'aide de l'annotation «joinTable». Cependant, la table de jointure était déjà définie par une annotation dans sa classe d'entité sous-jacente et je voulais qu'elle utilise les propres définitions de champ de cette classe d'entité d'association afin d'étendre la table de jointure avec des champs personnalisés supplémentaires.

L'explication et la solution sont celles identifiées par FMaz008 ci-dessus. Dans ma situation, c'était grâce à ce post dans le forum ' Doctrine Annotation Question '. Ce message attire l'attention sur la documentation de la Doctrine concernant les relations unidirectionnelles ManyToMany . Regardez la note concernant l'approche de l'utilisation d'une `` classe d'entités d'association '', remplaçant ainsi le mappage d'annotations plusieurs à plusieurs directement entre deux classes d'entités principales par une annotation un à plusieurs dans les classes d'entités principales et deux 'plusieurs à plusieurs'. -une 'annotation dans la classe d'entité associative. Il y a un exemple fourni dans ce forum post Modèles d'association avec des champs supplémentaires :

public class Person {

  /** @OneToMany(targetEntity="AssignedItems", mappedBy="person") */
  private $assignedItems;

}

public class Items {

    /** @OneToMany(targetEntity="AssignedItems", mappedBy="item") */
    private $assignedPeople;
}

public class AssignedItems {

    /** @ManyToOne(targetEntity="Person")
    * @JoinColumn(name="person_id", referencedColumnName="id")
    */
private $person;

    /** @ManyToOne(targetEntity="Item")
    * @JoinColumn(name="item_id", referencedColumnName="id")
    */
private $item;

}
Onshop
la source
3

Cet exemple vraiment utile. Il manque dans la doctrine de la documentation 2.

Merci beaucoup.

Pour les fonctions de proxy peuvent être effectuées:

class AlbumTrack extends AlbumTrackAbstract {
   ... proxy method.
   function getTitle() {} 
}

class TrackAlbum extends AlbumTrackAbstract {
   ... proxy method.
   function getTitle() {}
}

class AlbumTrackAbstract {
   private $id;
   ....
}

et

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

/** @OneToMany(targetEntity="AlbumTrack", mappedBy="track") */
protected $albumsFeaturingThisTrack;
Anthony
la source
3

Vous faites référence à des métadonnées, des données sur les données. J'ai eu ce même problème pour le projet sur lequel je travaille actuellement et j'ai dû passer un peu de temps à essayer de le comprendre. C'est trop d'informations à publier ici, mais voici deux liens qui peuvent vous être utiles. Ils font référence au framework Symfony, mais sont basés sur l'ORM Doctrine.

http://melikedev.com/2010/04/06/symfony-saving-metadata-during-form-save-sort-ids/

http://melikedev.com/2009/12/09/symfony-w-doctrine-saving-many-to-many-mm-relationships/

Bonne chance et belles références Metallica!

Mike Purcell
la source
3

La solution se trouve dans la documentation de Doctrine. Dans la FAQ, vous pouvez voir ceci:

http://docs.doctrine-project.org/en/2.1/reference/faq.html#how-can-i-add-columns-to-a-many-to-many-table

Et le tutoriel est ici:

http://docs.doctrine-project.org/en/2.1/tutorials/composite-primary-keys.html

Donc vous ne faites plus manyToManymais vous devez créer une entité supplémentaire et mettremanyToOne dans vos deux entités.

AJOUTER pour le commentaire @ f00bar:

c'est simple, il suffit de faire quelque chose comme ça:

Article  1--N  ArticleTag  N--1  Tag

Vous créez donc une entité ArticleTag

ArticleTag:
  type: entity
  id:
    id:
      type: integer
      generator:
        strategy: AUTO
  manyToOne:
    article:
      targetEntity: Article
      inversedBy: articleTags
  fields: 
    # your extra fields here
  manyToOne:
    tag:
      targetEntity: Tag
      inversedBy: articleTags

J'espère que ça aide

Mirza Selimovic
la source
C'est exactement ce que je cherchais, merci! Malheureusement, il n'y a pas d'exemple yml pour le troisième cas d'utilisation! :(Quelqu'un pourrait-il partager un exemple du troisième cas d'utilisation en utilisant le format yml? J'apprécierais vraiment:#
Stéphane
j'ai ajouté à la réponse votre cas;)
Mirza Selimovic
C'est incorrect. L'entité n'a pas besoin d'être avec id (id) AUTO. C'est faux, j'essaie de créer le bon exemple
Gatunox
je
posterai
3

Unidirectionnel. Ajoutez simplement inversedBy: (nom de la colonne étrangère) pour le rendre bidirectionnel.

# config/yaml/ProductStore.dcm.yml
ProductStore:
  type: entity
  id:
    product:
      associationKey: true
    store:
      associationKey: true
  fields:
    status:
      type: integer(1)
    createdAt:
      type: datetime
    updatedAt:
      type: datetime
  manyToOne:
    product:
      targetEntity: Product
      joinColumn:
        name: product_id
        referencedColumnName: id
    store:
      targetEntity: Store
      joinColumn:
        name: store_id
        referencedColumnName: id

J'espère que ça aide. À plus.

Gatunox
la source
2

Vous pourrez peut-être réaliser ce que vous voulez avec l' héritage de table de classe où vous changez AlbumTrackReference en AlbumTrack:

class AlbumTrack extends Track { /* ... */ }

Et getTrackList()contiendrait des AlbumTrackobjets que vous pourriez ensuite utiliser comme vous le souhaitez:

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

Vous devrez l'examiner attentivement pour vous assurer de ne pas souffrir en termes de performances.

Votre configuration actuelle est simple, efficace et facile à comprendre même si certaines sémantiques ne vous conviennent pas.

rojoca
la source
0

Lors de la création de toutes les pistes d'album dans la classe d'album, vous générez une requête de plus pour un enregistrement de plus. C'est à cause de la méthode proxy. Il y a un autre exemple de mon code (voir le dernier message dans la rubrique): http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868

Existe-t-il une autre méthode pour résoudre ce problème? Une seule jointure n'est-elle pas une meilleure solution?

quba
la source
1
Bien que cela puisse théoriquement répondre à la question, il serait préférable d'inclure ici les parties essentielles de la réponse et de fournir le lien de référence.
Spontifixus
0

Voici la solution décrite dans la documentation Doctrine2

<?php
use Doctrine\Common\Collections\ArrayCollection;

/** @Entity */
class Order
{
    /** @Id @Column(type="integer") @GeneratedValue */
    private $id;

    /** @ManyToOne(targetEntity="Customer") */
    private $customer;
    /** @OneToMany(targetEntity="OrderItem", mappedBy="order") */
    private $items;

    /** @Column(type="boolean") */
    private $payed = false;
    /** @Column(type="boolean") */
    private $shipped = false;
    /** @Column(type="datetime") */
    private $created;

    public function __construct(Customer $customer)
    {
        $this->customer = $customer;
        $this->items = new ArrayCollection();
        $this->created = new \DateTime("now");
    }
}

/** @Entity */
class Product
{
    /** @Id @Column(type="integer") @GeneratedValue */
    private $id;

    /** @Column(type="string") */
    private $name;

    /** @Column(type="decimal") */
    private $currentPrice;

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

/** @Entity */
class OrderItem
{
    /** @Id @ManyToOne(targetEntity="Order") */
    private $order;

    /** @Id @ManyToOne(targetEntity="Product") */
    private $product;

    /** @Column(type="integer") */
    private $amount = 1;

    /** @Column(type="decimal") */
    private $offeredPrice;

    public function __construct(Order $order, Product $product, $amount = 1)
    {
        $this->order = $order;
        $this->product = $product;
        $this->offeredPrice = $product->getCurrentPrice();
    }
}
médunes
la source