web-dev-qa-db-fra.com

Doctrine2: Meilleur moyen de gérer plusieurs-plusieurs avec des colonnes supplémentaires dans la table de référence

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

Supposons que nous ayons un album du type Master of Puppets de Metallica avec plusieurs pistes. Mais notez s'il vous plaît qu’une piste peut apparaître dans plusieurs albums, comme Battery de Metallica fait - trois albums sont mettant en vedette cette piste.

Donc, ce dont j'ai besoin, c'est d'une relation multiple entre albums et 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 obtenir 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;
    }
}

Échantillon 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 des albums et des 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 à quoi je m'attendais, à savoir: une liste d'albums avec leurs pistes dans l'ordre approprié et les albums promus 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'objets AlbumTrackReference au lieu d'objets Track. Je ne peux pas créer de méthodes proxy car si Album et Track avaient la méthode getTitle()? Je pourrais faire un traitement supplémentaire dans la méthode Album::getTracklist(), mais quel est le moyen le 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

MODIFIER

@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 méthode getTitle() devrait 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.

274
Crozin

J'ai ouvert une question similaire dans la liste de diffusion 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éaliserez que vous avez 3 objets, liés entre eux par 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!

157
FMaz008

À partir de $ album-> getTrackList (), vous obtiendrez toujours des entités "AlbumTrackReference", 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 tous les autres codes liés à la boucle des pistes d’un album, puisque toutes les méthodes sont simplement proxy 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 morceaux qui ne sont pas connectés à un album mais juste disponibles via un cd promo ou quelque chose qui permet une séparation plus nette également.

17
beberlei

Rien ne vaut un bel exemple

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

Bel exemple d'association un-plusieurs-plusieurs/plusieurs-pour-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. Doctrine supporte cela de manière native. Vous pouvez transformer vos entités référencées en identifiants. Consultez la documentation sur les clés composites ici

13
Wilt

Je pense que j'accepterais 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 Album et votre Track peuvent les implémenter, alors que le AlbumTrackReference peut 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 référençant directement une Track ou une Album, et en la remplaçant simplement pour qu'elle utilise un TrackInterface ou AlbumInterface, vous utiliserez 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érenciera pas la logique DQL ni celle du référentiel, mais vos services ignoreront simplement le fait que vous transmettez un 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 t'aides!

10
Ocramius

Premièrement, je suis surtout d’accord avec beberlei sur ses suggestions. Cependant, vous pouvez vous concevoir comme un piège. Votre domaine semble considérer que le titre est la clé naturelle d'une piste, ce qui est probablement le cas pour 99% des scénarios que vous rencontrez. Cependant, que faire si Battery sur Master of the Puppets est une version différente (durée, live, acoustique, remix, remasterisé, etc.) de la version de La collection Metallica.

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 aller avec votre 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 charge mon cas d'utilisation, vous pouvez faire en sorte que les pistes contiennent un auto-référence OneToMany avec d'autres pistes, éventuellement $ similarTracks. Dans ce cas, il y aurait deux entités pour la piste Batterie, une pour La collection Metallica et une pour Maître des marionnettes. Chaque entité de piste similaire contiendrait alors une référence les unes aux autres. En outre, cela éliminerait la classe actuelle AlbumTrackReference et éliminerait votre "problème" actuel. Je conviens qu'il s'agit simplement de déplacer la complexité vers un point différent, mais il est capable de gérer un cas d'utilisation auquel il n'était pas capable auparavant.

7
jsuggs

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 manière dont vous souhaitez gérer et/ou encapsuler la gestion des associations lors de l'utilisation de classes d'association dépend de votre domaine et de votre choix. Personne ne peut vous montrer le "meilleur moyen", j'en ai bien peur.

En dehors de cela, la question pourrait être beaucoup simplifiée en supprimant 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 en langage OOP simple.

6
romanb

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 ont apparemment abouti à 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 souhaitais 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 message dans le forum ' Doctrine Annotation Question '. Cet article attire l’attention sur la Doctrine documentation concernant Plusieurs relations unidirectionnelles . Observez la remarque concernant l'approche consistant à utiliser 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". -one 'annotations dans la classe d'entités associatives. Un exemple est fourni dans cet article de forum 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;

}
6
Onshop

La solution est 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 de manyToMany mais vous devez créer une entité supplémentaire et mettre manyToOne dans vos deux entités.

AJOUTE pour le commentaire @ f00bar:

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

Article  1--N  ArticleTag  N--1  Tag

Donc, vous créez 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

3
Mirza Selimovic

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

Merci beaucoup.

Pour les mandataires, les fonctions peuvent être réalisé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;
3
Anthony

Unidirectionnel. Ajoutez simplement l'inverse de: (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.

3
Gatunox

Vous parlez de métadonnées, de données sur les données. J'ai eu le même problème pour le projet sur lequel je travaille actuellement et j'ai dû passer du temps à essayer de le comprendre. C’est trop d’informations à publier ici, mais vous trouverez ci-dessous deux liens utiles. Ils font référence au framework Symfony, mais sont basés sur le Doctrine ORM.

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 références Metallica!

3
Mike Purcell

Vous pourrez peut-être réaliser ce que vous voulez avec Class Table Hheritance en modifiant AlbumTrackReference en AlbumTrack:

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

Et getTrackList() contiendrait AlbumTrack objets 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 examiner cela à fond pour vous assurer de ne pas nuire à la performance.

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

2
rojoca

Tout en obtenant toutes les pistes de l'album dans la classe d'albums, vous allez générer une requête supplémentaire pour un enregistrement supplémentaire. C'est à cause de la méthode du proxy. Voici un autre exemple de code (voir le dernier message de 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?

0
quba

Voici la solution décrite dans le Documentation de 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();
    }
}
0
medunes