Me pregunto cuál es la mejor, la forma más limpia y sencilla de trabajar con relaciones de muchos a muchos en Doctrine2.
Supongamos que tenemos un álbum como Master of Puppets de Metallica con varias pistas. Pero tenga en cuenta el hecho de que una pista puede aparecer en más de un álbum, como lo hace Battery by Metallica : tres álbumes presentan esta pista.
Entonces, lo que necesito es una relación de muchos a muchos entre álbumes y pistas, usando la tercera tabla con algunas columnas adicionales (como la posición de la pista en el álbum especificado). En realidad, tengo que usar, como sugiere la documentación de Doctrine, una doble relación 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 lo que espero, es decir: una lista de álbumes con sus pistas en el orden apropiado y los promocionados que se marcan como promocionados.
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 AlbumTrackReference
objetos en lugar de Track
objetos. No puedo crear métodos proxy porque ¿qué pasa si ambos Album
y Track
tendrían getTitle()
método? Podría hacer un procesamiento adicional dentro del Album::getTracklist()
método, pero ¿cuál es la forma más sencilla de hacerlo? ¿Estoy 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ó usar 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 tanto, el getTitle()
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 forma muy limpia.
$album->getTracklist()[12]
es AlbumTrackRef
objeto, por $album->getTracklist()[12]->getTitle()
lo que siempre devolverá el título de la pista (si está utilizando el método proxy). Si bien $track->getAlbums()[1]
es Album
objeto, por $track->getAlbums()[1]->getTitle()
lo tanto , siempre se devolverá el título del álbum.
AlbumTrackReference
dos métodos proxy getTrackTitle()
y getAlbumTitle
.