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

282

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 AlbumTrackReferenceobjetos en lugar de Trackobjetos. No puedo crear métodos proxy porque ¿qué pasa si ambos Albumy Tracktendrí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.

Crozin
fuente
2
¿Cómo manejas la referencia de AlbumTrack? Por ejemplo $ album-> addTrack () o $ album-> removeTrack ()?
Daniel
No entendí tu comentario sobre el contexto. En mi opinión, los datos no dependen del contexto. Acerca de $album->getTracklist()[12]es AlbumTrackRefobjeto, 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 Albumobjeto, por $track->getAlbums()[1]->getTitle()lo tanto , siempre se devolverá el título del álbum.
Vinícius Fagundes
Otra idea es usar AlbumTrackReferencedos métodos proxy getTrackTitle()y getAlbumTitle.
Vinícius Fagundes

Respuestas:

158

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 da 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
fuente
¿Alguien sabe cómo puedo obtener la herramienta de línea de comandos de doctrina para generar esta nueva entidad como un archivo de esquema yml? Este comando: app/console doctrine:mapping:import AppBundle ymltodavía genera una relación manyToMany para las dos tablas originales y simplemente ignora la tercera tabla en lugar de considerarla como una entidad:/
Stphane
¿ foreach ($album->getTracklist() as $track) { echo $track->getTrack()->getTitle(); }Cuál es la diferencia entre provisto por @Crozin y consider the relationship as an entity? Creo que lo que quiere preguntar es cómo omitir la entidad relacional y recuperar el título de una pista usandoforeach ($album->getTracklist() as $track) { echo $track->getTitle(); }
panda
66
"Una vez que una relación tiene datos, ya no es una relación" Esto fue realmente esclarecedor. ¡Simplemente no podía pensar en una relación desde la perspectiva de una entidad!
Cebolla
¿Qué pasa si la relación ya fue creada y utilizada como muchos? Nos dimos cuenta de que necesitábamos campos adicionales en nuestros muchos a muchos, así que creamos una entidad diferente. El problema es que, con los datos existentes y una tabla existente con el mismo nombre, no parece querer ser amigos. ¿Alguien ha intentado esto antes?
tylerism
Para aquellos que se preguntan: crear una Entidad con el (ya existente) de muchos a muchos articulables a medida que su tabla funciona, sin embargo, las entidades que poseen los muchos a muchos deben adaptarse en su lugar a uno a muchos a la nueva entidad. También es probable que las interfaces con el exterior (getters / setters para los anteriores muchos-a-muchos) tengan que adaptarse.
Jakumi
17

Desde $ album-> getTrackList () siempre obtendrá las entidades "AlbumTrackReference", entonces, ¿qué pasa con la adición de métodos desde el Track y el 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, al igual que todos los demás códigos relacionados con el bucle de las pistas de un álbum, ya que todos los métodos solo se representan 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, debe cambiar el nombre de AlbumTrackReference (por ejemplo, "AlbumTrack"). Claramente no es solo una referencia, sino que contiene lógica adicional. Dado que probablemente también hay pistas que no están conectadas a un álbum, sino 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
fuente
1
Los métodos de proxy no resuelven el problema al 100% (consulte mi edición). Btw You should rename the AlbumT(...)- Buen punto
Crozin
3
¿Por qué no tienes dos métodos? getAlbumTitle () y getTrackTitle () en el objeto AlbumTrackReference? Ambos apoyan sus respectivos subobjetos.
beberlei
El objetivo es la API de objetos más natural . $album->getTracklist()[1]->getTrackTitle()es tan bueno / malo como $album->getTracklist()[1]->getTrack()->getTitle(). Sin embargo, parece que tendría que tener dos clases diferentes: una para referencias de álbum-> pista y otra para referencias de pista-> álbumes, y eso es demasiado difícil de implementar. Probablemente esa sea la mejor solución hasta ahora ...
Crozin
13

Nada mejor que un buen ejemplo

Para las personas que buscan un ejemplo de codificación limpia de una asociación 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

También piense en su clave principal. A menudo puede usar claves compuestas para relaciones como esta. Doctrine apoya esto de forma nativa. Puede convertir sus entidades referenciadas en identificadores. Consulte la documentación sobre claves compuestas aquí

Marchitar
fuente
10

Creo que iría con la sugerencia de @ beberlei de usar métodos proxy. Lo que puede hacer para simplificar este proceso es definir dos interfaces:

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

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

Luego, tanto tu Albumcomo tu Trackpueden implementarlos, mientras AlbumTrackReferenceque aún pueden implementar ambos, de la siguiente manera:

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 esta manera, eliminando su lógica que hace referencia directa a a Tracko an Album, y simplemente reemplazándola para que use un TrackInterfaceo AlbumInterface, puede usar su AlbumTrackReferenceen cualquier caso posible. Lo que necesitará es diferenciar un poco los métodos entre las interfaces.

Esto no diferenciará el DQL ni la lógica del repositorio, pero sus servicios simplemente ignorarán el hecho de que está pasando una Albumo una AlbumTrackReference, o una Tracko una AlbumTrackReferenceporque ha ocultado todo detrás de una interfaz :)

¡Espero que esto ayude!

Ocramius
fuente
7

Primero, estoy de acuerdo con beberlei en sus sugerencias. Sin embargo, es posible que te estés diseñando en una trampa. Su dominio parece estar considerando que el título es la clave natural para una pista, lo que probablemente sea el caso del 99% de los escenarios con los que se encuentra. Sin embargo, ¿qué pasa si Battery on Master of the Puppets es una versión diferente (diferente duración, live, acústica, remix, remasterizada, etc.) que la versión de The Metallica Collection .

Dependiendo de cómo desee manejar (o ignorar) ese caso, puede seguir la ruta sugerida de beberlei o simplemente ir con su lógica adicional propuesta en Album :: getTracklist (). Personalmente, creo que la lógica adicional está justificada para mantener limpia su API, pero ambas tienen sus méritos.

Si desea acomodar mi caso de uso, puede hacer que las Pistas contengan un OneToMany autorreferenciado a otras Pistas, posiblemente $ Pistas similares. En este caso, habría dos entidades para la pista Battery , una para The Metallica Collection y otra para Master of the Puppets . Entonces cada entidad Track similar contendría una referencia entre sí. Además, eso eliminaría la clase actual AlbumTrackReference y eliminaría su "problema" actual. Estoy de acuerdo en que solo está moviendo la complejidad a un punto diferente, pero es capaz de manejar un caso de uso que antes no podía.

jsuggs
fuente
6

Pides la "mejor manera" pero no hay la mejor manera. Hay muchas formas y ya descubriste algunas de ellas. La forma en que desea administrar y / o encapsular la administración de asociaciones cuando usa clases de asociación depende totalmente de usted y de su dominio concreto, nadie puede mostrarle la "mejor manera", me temo.

Aparte de eso, la pregunta podría simplificarse mucho eliminando Doctrine y las bases de datos relacionales de la ecuación. La esencia de su pregunta se reduce a una pregunta sobre cómo lidiar con las clases de asociación en OOP simple.

romanb
fuente
6

Estaba obteniendo un conflicto con la tabla de unión definida en una anotación de clase de asociación (con campos personalizados adicionales) y una tabla de unión definida en una anotación de muchos a muchos.

Las definiciones de mapeo en dos entidades con una relación directa de muchos a muchos parecían dar como resultado la creación automática de la tabla de combinación mediante la anotación 'joinTable'. Sin embargo, la tabla de unión ya estaba definida por una anotación en su clase de entidad subyacente y quería que usara las propias definiciones de campo de esta clase de entidad de asociación para extender la tabla de unión con campos personalizados adicionales.

La explicación y solución es la identificada por FMaz008 arriba. En mi situación, fue gracias a esta publicación en el foro ' Doctrine Annotation Question '. Esta publicación llama la atención sobre la documentación de Doctrine con respecto a las relaciones unidireccionales ManyToMany . Mire la nota con respecto al enfoque de usar una 'clase de entidad de asociación', reemplazando así el mapeo de anotaciones de muchos a muchos directamente entre dos clases de entidades principales con una anotación de uno a muchos en las clases de entidades principales y dos 'muchos a -una 'anotaciones en la clase de entidad asociativa. Hay un ejemplo proporcionado en esta publicación del foro Modelos de asociación con campos adicionales :

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
fuente
3

Este ejemplo realmente útil. Carece de documentación en la doctrina 2.

Muchas gracias.

Para las funciones de proxy se pueden hacer:

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

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

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

y

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

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

A lo que se refiere es a metadatos, datos sobre datos. Tuve este mismo problema para el proyecto en el que estoy trabajando actualmente y tuve que pasar algún tiempo tratando de resolverlo. Es demasiada información para publicar aquí, pero a continuación hay dos enlaces que pueden resultarle útiles. Hacen referencia al marco de Symfony, pero se basan en 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/

¡Buena suerte y buenas referencias de Metallica!

Mike Purcell
fuente
3

La solución está en la documentación de Doctrine. En las preguntas frecuentes puede ver esto:

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

Y el tutorial está aquí:

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

Entonces ya no haces un manyToManypero tienes que crear una Entidad extra y poner manyToOnea tus dos entidades.

AGREGAR para el comentario de @ f00bar:

es simple, solo tienes que hacer algo como esto:

Article  1--N  ArticleTag  N--1  Tag

Entonces creas una entidad 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

Espero que ayude

Mirza Selimovic
fuente
Eso es exactamente lo que estaba buscando, ¡gracias! Desafortunadamente, ¡no hay un ejemplo de yml para el tercer caso de uso! :(¿Alguien podría compartir un ejemplo del tercer caso de uso usando el formato yml? Realmente me appriace:#
Stéphane
He agregado a la respuesta su caso;)
Mirza Selimovic
Es incorrecto La entidad no tiene que estar con id (id) AUTO. Eso está mal, estoy tratando de crear el ejemplo correcto
Gatunox
Publicaré una nueva respuesta para obtener si está formateado correctamente
Gatunox
3

Unidireccional Simplemente agregue el invertedBy: (Nombre de columna extranjera) para hacerlo bidireccional.

# 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

Espero que ayude. Nos vemos.

Gatunox
fuente
2

Es posible que pueda lograr lo que desea con Herencia de tabla de clase donde cambia AlbumTrackReference a AlbumTrack:

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

Y getTrackList()contendría AlbumTrackobjetos que luego podría usar como desee:

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

Tendrá que examinar esto a fondo para asegurarse de que no sufra en cuanto al rendimiento.

Su configuración actual es simple, eficiente y fácil de entender, incluso si algunas de las semánticas no le sientan bien.

rojoca
fuente
0

Mientras obtiene todas las pistas del álbum dentro de la clase de álbum, generará una consulta más para un registro más. Eso se debe al método proxy. Hay otro ejemplo de mi código (vea la última publicación en el tema): http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868

¿Hay algún otro método para resolver eso? ¿No es una sola combinación una mejor solución?

quba
fuente
1
Si bien esto puede responder teóricamente la pregunta, sería preferible incluir aquí las partes esenciales de la respuesta y proporcionar el enlace para referencia.
Spontifixus
0

Aquí está la solución como se describe en la documentación 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();
    }
}
medunes
fuente