Doctrine2: la mejor manera de manejar muchos a muchos con columnas adicionales en la tabla de referencia

Resuelto Crozin asked hace 54 años • 13 respuestas

Me pregunto cuál es la mejor, más limpia y sencilla forma de trabajar con relaciones de muchos a muchos en Doctrine2.

Supongamos que tenemos un álbum como Master of Puppets de Metallica con varios temas. Pero tenga en cuenta el hecho de que una pista puede aparecer en más de un álbum, como lo hace Battery de Metallica : tres álbumes incluyen esta pista.

Entonces, lo que necesito es una relación de muchos a muchos entre álbumes y pistas, usando una tercera tabla con algunas columnas adicionales (como la posición de la pista en el álbum específico). En realidad, tengo que usar, como sugiere la documentación de Doctrine, una relación doble de uno a muchos para lograr esa funcionalidad.

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

Data de muestra:

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

Ahora puedo mostrar una lista de álbumes y pistas asociadas a ellos:

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

Los resultados son los que esperaba, es decir: una lista de álbumes con sus pistas en el orden apropiado y las promocionadas marcadas como promocionadas.

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) 

¿Así que qué hay de malo?

Este código demuestra lo que está mal:

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

Album::getTracklist()devuelve una matriz de AlbumTrackReferenceobjetos en lugar de Trackobjetos. No puedo crear métodos proxy porque ¿qué pasaría si ambos Albumy Tracktuvieran getTitle()un método? Podría realizar un procesamiento adicional dentro Album::getTracklist()del método, pero ¿cuál es la forma más sencilla de hacerlo? ¿Me veo obligado a escribir algo así?

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 sugirió utilizar métodos proxy:

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

Sería una buena idea, pero estoy usando ese "objeto de referencia" de ambos lados: $album->getTracklist()[12]->getTitle()y $track->getAlbums()[1]->getTitle(), por lo que getTitle()el método debería devolver datos diferentes según el contexto de invocación.

Tendría que hacer algo como:

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

Y esa no es una manera muy limpia.

Crozin avatar Jan 01 '70 08:01 Crozin
Aceptado

Abrí una pregunta similar en la lista de correo de usuarios de Doctrine y obtuve una respuesta realmente simple;

Considere la relación de muchos a muchos como una entidad en sí misma, y ​​luego se dará cuenta de que tiene 3 objetos, vinculados entre ellos con una relación de uno a muchos y de muchos a uno.

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

Una vez que una relación tiene datos, ¡ya no es una relación!

FMaz008 avatar Nov 02 '2011 14:11 FMaz008

Desde $album->getTrackList() siempre recuperará las entidades "AlbumTrackReference", entonces, ¿qué tal si agrega métodos desde Track y proxy?

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

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

De esta manera, su bucle se simplifica considerablemente, así como todo el resto del código relacionado con el bucle de las pistas de un álbum, ya que todos los métodos simplemente se transfieren dentro de 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!' : ''
    );
}

Por cierto, debes cambiar el nombre de AlbumTrackReference (por ejemplo, "AlbumTrack"). Claramente no es sólo una referencia, sino que contiene una lógica adicional. Dado que probablemente también hay pistas que no están conectadas a un álbum pero que solo están disponibles a través de un CD promocional o algo así, esto también permite una separación más limpia.

beberlei avatar Aug 25 '2010 11:08 beberlei

Nada supera un buen ejemplo

Para las personas que buscan un ejemplo de codificación limpio de asociaciones uno a muchos/muchos a uno entre las 3 clases participantes para almacenar atributos adicionales en la relación, consulte este sitio:

buen ejemplo de asociaciones uno a muchos/muchos a uno entre las 3 clases participantes

Piensa en tus claves principales

Piense también en su clave principal. A menudo puedes utilizar claves compuestas para relaciones como esta. La doctrina apoya esto de forma nativa. Puede convertir sus entidades referenciadas en identificadores. Consulta la documentación sobre claves compuestas aquí

Wilt avatar Apr 05 '2013 16:04 Wilt