Doctrine2: Il modo migliore per gestire il molti-a-molti con colonne extra nella tabella di riferimento


282

Mi chiedo quale sia il modo migliore, più pulito e più semplice per lavorare con le relazioni molti-a-molti in Doctrine2.

Supponiamo che abbiamo un album come Master of Puppets dei Metallica con diverse tracce. Ma tieni presente che una traccia potrebbe apparire in più di un album, come fa Battery dei Metallica : tre album presentano questa traccia.

Quindi quello di cui ho bisogno è una relazione molti-a-molti tra album e tracce, usando la terza tabella con alcune colonne aggiuntive (come la posizione della traccia nell'album specificato). In realtà devo usare, come suggerisce la documentazione di Doctrine, una doppia relazione uno-a-molti per raggiungere quella funzionalità.

/** @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;
    }
}

Dati di esempio:

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

Ora posso visualizzare un elenco di album e tracce ad essi associati:

$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!' : ''
        );
    }   
}

I risultati sono quello che mi aspetto, ovvero: un elenco di album con le loro tracce nell'ordine appropriato e quelli promossi che vengono contrassegnati come promossi.

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) 

Quindi cosa c'è che non va?

Questo codice dimostra cosa c'è che non va:

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

Album::getTracklist()restituisce una matrice di AlbumTrackReferenceoggetti anziché Trackoggetti. Non riesco a creare metodi proxy perché cosa succede se entrambi, Albume Trackavrebbe getTitle()metodo? Potrei fare qualche elaborazione extra all'interno del Album::getTracklist()metodo, ma qual è il modo più semplice per farlo? Sono costretto a scrivere qualcosa del genere?

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

MODIFICARE

@beberlei ha suggerito di utilizzare i metodi proxy:

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

Sarebbe una buona idea ma sto usando quell'oggetto di riferimento da entrambi i lati: $album->getTracklist()[12]->getTitle()e $track->getAlbums()[1]->getTitle()quindi il getTitle()metodo dovrebbe restituire dati diversi in base al contesto dell'invocazione.

Dovrei fare qualcosa del tipo:

 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();
 }

E questo non è un modo molto pulito.


2
Come gestite AlbumTrackReference? Ad esempio $ album-> addTrack () o $ album-> removeTrack ()?
Daniel,

Non ho capito il tuo commento sul contesto. A mio avviso, i dati non dipendono dal contesto. About $album->getTracklist()[12]is AlbumTrackRefobject, quindi $album->getTracklist()[12]->getTitle()restituirà sempre il titolo della traccia (se si utilizza il metodo proxy). While $track->getAlbums()[1]is Albumobject, quindi $track->getAlbums()[1]->getTitle()restituirà sempre il titolo dell'album.
Vinícius Fagundes il

Un'altra idea sta usando AlbumTrackReferencedue metodi proxy getTrackTitle()e getAlbumTitle.
Vinícius Fagundes il

Risposte:


158

Ho aperto una domanda simile nella mailing list degli utenti di Doctrine e ho ottenuto una risposta davvero semplice;

considera la relazione molti a molti come un'entità stessa, e poi ti rendi conto di avere 3 oggetti, collegati tra loro con una relazione uno-a-molti e molti-a-uno.

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

Una volta che una relazione ha dati, non è più una relazione!


Qualcuno sa come posso ottenere lo strumento da riga di comando dottrina per generare questa nuova entità come file di schema yml? Questo comando: app/console doctrine:mapping:import AppBundle ymlgenera ancora molte relazioni ToMany per le due tabelle originali e semplicemente ignora la terza tabella invece di condividerla come entità:/
Stphane

qual è la differenza tra foreach ($album->getTracklist() as $track) { echo $track->getTrack()->getTitle(); }fornita da @Crozin e consider the relationship as an entity? Penso che quello che vuole chiedere sia come saltare l'entità relazionale e recuperare il titolo di una traccia usandoforeach ($album->getTracklist() as $track) { echo $track->getTitle(); }
panda il

6
"Una volta che una relazione ha dati, non è più una relazione" Questo è stato davvero illuminante. Non riuscivo a pensare a una relazione dal punto di vista dell'entità!
Cipolla,

Che dire se la relazione fosse già stata creata e utilizzata da molti a molti. Ci siamo resi conto che avevamo bisogno di campi extra nei nostri molti a molti, quindi abbiamo creato un'entità diversa. Il problema è che, con i dati esistenti e una tabella esistente con lo stesso nome, non sembra voler essere amici. Qualcuno l'ha già provato?
Tylerism,

Per quelli che si chiedono: la creazione di un'entità con il moltiplicabile (già esistente) moltiplicabile mentre funziona la sua tabella, tuttavia, le entità che detengono il molti-a-molti devono essere adattate invece a uno-a-molti alla nuova entità. anche le interfacce verso l'esterno (getter / setter per l'ex molti-a-molti) devono probabilmente essere adattate.
Jakumi,

17

Da $ album-> getTrackList () otterrai sempre le entità "AlbumTrackReference", quindi che ne dici di aggiungere metodi dalla traccia e dal proxy?

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

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

In questo modo il tuo loop si semplifica considerevolmente, così come tutti gli altri codici relativi al loop delle tracce di un album, poiché tutti i metodi sono solo sottoposti a proxy all'interno di 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 Dovresti rinominare AlbumTrackReference (ad esempio "AlbumTrack"). Chiaramente non è solo un riferimento, ma contiene una logica aggiuntiva. Dato che probabilmente ci sono anche tracce che non sono collegate ad un album ma sono disponibili solo attraverso un cd promozionale o qualcosa del genere, ciò consente anche una separazione più pulita.


1
I metodi proxy non risolvono il problema al 100% (controlla la mia modifica). Btw You should rename the AlbumT(...)- buon punto
Crozin

3
Perché non hai due metodi? getAlbumTitle () e getTrackTitle () sull'oggetto AlbumTrackReference? Entrambi eseguono il proxy nei rispettivi oggetti secondari.
Beberlei,

L'obiettivo è l' API per oggetti più naturale . $album->getTracklist()[1]->getTrackTitle()è buono / cattivo come $album->getTracklist()[1]->getTrack()->getTitle(). Tuttavia sembra che dovrei avere due classi diverse: una per i riferimenti all'album-> traccia e l'altra per i riferimenti-> album - ed è troppo difficile da implementare. Quindi probabilmente questa è la soluzione migliore finora ...
Crozin,

13

Niente batte un bell'esempio

Per le persone in cerca di un chiaro esempio di codifica di associazioni uno-a-molti / molti-a-uno tra le 3 classi partecipanti per memorizzare ulteriori attributi nella relazione, consulta questo sito:

bell'esempio di associazioni one-to-many / many-to-one tra le 3 classi partecipanti

Pensa alle tue chiavi primarie

Pensa anche alla tua chiave primaria. Spesso puoi usare chiavi composite per relazioni come questa. La dottrina lo supporta nativamente. Puoi trasformare le tue entità referenziate in ID. Controlla la documentazione sui tasti compositi qui


10

Penso che vorrei seguire il suggerimento di @ beberlei di usare i metodi proxy. Quello che puoi fare per semplificare questo processo è definire due interfacce:

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

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

Quindi, sia tu Albumche tu Trackpuoi implementarli, mentre AlbumTrackReferenceentrambi possono ancora implementarli entrambi, come segue:

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();
    }
}

In questo modo, rimuovendo la logica che fa direttamente riferimento a Tracko a Albume semplicemente sostituendola in modo che utilizzi TrackInterfaceo AlbumInterface, è possibile utilizzare la propria AlbumTrackReferencein ogni caso possibile. Ciò di cui avrai bisogno è differenziare un po 'i metodi tra le interfacce.

Questo non differenzierà il DQL né la logica del repository, ma i tuoi servizi ignoreranno semplicemente il fatto che stai passando un Albumo un AlbumTrackReference, o un Tracko un AlbumTrackReferenceperché hai nascosto tutto dietro un'interfaccia :)

Spero che questo ti aiuti!


7

Innanzitutto, concordo principalmente con Beberlei sui suoi suggerimenti. Tuttavia, potresti progettare te stesso in una trappola. Il tuo dominio sembra considerare il titolo come la chiave naturale di una traccia, il che è probabilmente il caso del 99% degli scenari che incontri. Tuttavia, cosa succede se Battery on Master of the Puppets è una versione diversa (diversa lunghezza, live, acustica, remix, rimasterizzata, ecc.) Rispetto alla versione di The Metallica Collection .

A seconda di come vuoi gestire (o ignorare) quel caso, puoi seguire il percorso suggerito da beberlei o semplicemente seguire la logica aggiuntiva proposta in Album :: getTracklist (). Personalmente, penso che la logica aggiuntiva sia giustificata per mantenere pulita l'API, ma entrambe hanno il loro valore.

Se si desidera soddisfare il mio caso d'uso, è possibile che le tracce contengano OneToMany autoreferenziali rispetto ad altre tracce, possibilmente $ similarTracks. In questo caso, ci sarebbero due entità per la traccia Battery , una per The Metallica Collection e una per Master of the Puppets . Quindi ogni entità Traccia simile conterrebbe un riferimento reciproco. Inoltre, ciò eliminerebbe l'attuale classe AlbumTrackReference ed eliminerebbe l'attuale "problema". Sono d'accordo sul fatto che stia semplicemente spostando la complessità su un altro punto, ma è in grado di gestire un caso d'uso che prima non era in grado di fare.


6

Chiedete il "modo migliore" ma non esiste il modo migliore. Ci sono molti modi e ne hai già scoperto alcuni. Il modo in cui vuoi gestire e / o incapsulare la gestione delle associazioni quando usi le classi di associazione dipende interamente da te e dal tuo dominio concreto, nessuno può mostrarti un "modo migliore", temo.

A parte questo, la domanda potrebbe essere semplificata molto rimuovendo la dottrina e i database relazionali dall'equazione. L'essenza della tua domanda si riduce a una domanda su come gestire le classi di associazione in semplice OOP.


6

Stavo arrivando da un conflitto con la tabella di join definita in un'annotazione di classe di associazione (con campi personalizzati aggiuntivi) e una tabella di join definita in un'annotazione molti-a-molti.

Le definizioni di mappatura in due entità con una relazione molti-a-diretta diretta sembravano comportare la creazione automatica della tabella di join usando l'annotazione 'joinTable'. Tuttavia, la tabella di join era già definita da un'annotazione nella sua classe di entità sottostante e volevo che usasse le definizioni di campo proprie di questa classe di entità di associazione in modo da estendere la tabella di join con campi personalizzati aggiuntivi.

La spiegazione e la soluzione è quella identificata da FMaz008 sopra. Nella mia situazione, è stato grazie a questo post nel forum " Doctrine Annotation Question ". Questo post richiama l'attenzione sulla documentazione di Dottrina riguardante le relazioni unidirezionali ManyToMany . Guarda la nota relativa all'approccio dell'uso di una "classe di entità di associazione" sostituendo così la mappatura delle annotazioni molti-a-molti direttamente tra due classi di entità principali con un'annotazione una-a-molte nelle classi di entità principali e due "molti-a -one 'annotazioni nella classe di entità associativa. C'è un esempio fornito in questo post sul forum Modelli di associazione con campi extra :

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;

}

3

Questo esempio davvero utile. Manca nella documentazione dottrina 2.

Molte grazie.

Per le funzioni proxy è possibile eseguire:

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

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

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

e

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

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

3

A cosa ti riferisci sono i metadati, i dati sui dati. Ho avuto lo stesso problema per il progetto a cui sto attualmente lavorando e ho dovuto dedicare un po 'di tempo a cercare di capirlo. Sono troppe le informazioni da pubblicare qui, ma di seguito sono riportati due collegamenti che potresti trovare utili. Fanno riferimento al framework Symfony, ma si basano sull'ORM di 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/

Buona fortuna e belle referenze Metallica!


3

La soluzione è nella documentazione di Doctrine. Nelle FAQ puoi vedere questo:

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

E il tutorial è qui:

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

Quindi non fai più una manyToManyma devi creare un'entità extra e metterla manyToOnenelle tue due entità.

AGGIUNGI per il commento di @ f00bar:

è semplice, devi solo fare qualcosa del genere:

Article  1--N  ArticleTag  N--1  Tag

Quindi crei un'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

spero possa essere d'aiuto



Questo è esattamente quello che stavo cercando, grazie! Sfortunatamente, non esiste un esempio yml per il terzo caso d'uso! :(Qualcuno potrebbe condividere un esempio del terzo caso d'uso usando il formato yml? Mi piacerebbe davvero:#
Stphane il

ho aggiunto alla risposta il tuo caso;)
Mirza Selimovic,

Non è corretto. L'entità non deve essere con id (id) AUTO. È sbagliato, sto cercando di creare l'esempio corretto
Gatunox,


3

Unidirezionale. Basta aggiungere inversedBy: (nome colonna esterna) per renderlo bidirezionale.

# 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

Spero possa essere d'aiuto. Ci vediamo.


2

Potresti riuscire a ottenere ciò che desideri con Ereditarietà delle tabelle di classe in cui cambi AlbumTrackReference in AlbumTrack:

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

E getTrackList()conterrebbe AlbumTrackoggetti che potresti usare come vuoi:

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!' : ''
    );
}

Dovrai esaminarlo attentamente per assicurarti di non soffrire in termini di prestazioni.

La tua configurazione attuale è semplice, efficiente e di facile comprensione anche se alcune delle semantiche non ti stanno proprio bene.


0

Mentre ottieni tutte le tracce degli album nella classe degli album, genererai un'altra query per un altro record. Questo è a causa del metodo proxy. C'è un altro esempio del mio codice (vedi l'ultimo post nell'argomento): http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868

C'è qualche altro metodo per risolverlo? Un singolo join non è una soluzione migliore?


1
Sebbene ciò possa teoricamente rispondere alla domanda, sarebbe preferibile includere qui le parti essenziali della risposta e fornire il collegamento come riferimento.
Spontifixus,

0

Ecco la soluzione come descritto nella documentazione di 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();
    }
}
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.