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


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

Supongamos que tenemos un álbum como Master of Puppets de Metallica con varias pistas. Pero tenga en cuenta el hecho de que una pista podría aparecer en más de un álbum, como Battery de Metallica does - tres álbumes presentan esta canción.

Así que 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 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;
    }
}

Datos 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 estoy esperando, es decir: una lista de álbumes con sus pistas en el orden apropiado y promocionados siendo marcado como promovido.

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) 

Entonces, ¿qué pasa?

Este código demuestra lo que está mal:

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

Album::getTracklist() devuelve un array de objetos AlbumTrackReference en lugar de objetos Track. No puedo crear métodos proxy causa ¿qué pasa si ambos, Album y Track tendrían getTitle() método? Podría hacer algún procesamiento adicional dentro del método Album::getTracklist(), pero ¿cuál es la forma más simple 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 proxy métodos:

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

Eso 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 el método getTitle() debe devolver datos diferentes basados en el contexto de la invocación.

Tendría que hacer algo como:{[19]]}

 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.

Author: S.L. Barth, 2010-08-22

14 answers

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

Considera la relación muchos a muchos como una entidad en sí misma, y luego te das cuenta de que tienes 3 objetos, vinculados entre ellos con una relación uno a muchos y 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 !

 152
Author: FMaz008,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2011-11-02 14:10:41

Desde album album- > getTrackList () siempre obtendrás entidades "AlbumTrackReference", así que ¿qué hay de agregar métodos desde la pista 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, así como todo el resto del código relacionado con el bucle de las pistas de un álbum, ya que todos los métodos son solo proxied 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, debería cambiar el nombre de AlbumTrackReference (por ejemplo "AlbumTrack"). Claramente no es solo una referencia, sino que contiene una 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, esto también permite una separación más limpia.

 17
Author: beberlei,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2010-08-25 17:00:17

No hay 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 primarias

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

 13
Author: Wilt,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-11-22 07:55:51

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

Entonces, tanto su Album como su Track pueden implementarlos, mientras que el AlbumTrackReference todavía puede 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, al eliminar la lógica que hace referencia directa a un Track o un Album, y simplemente reemplazarla para que use un TrackInterface o AlbumInterface, puede usar su AlbumTrackReference en cualquier caso posible. Lo usted necesitará es diferenciar los métodos entre las interfaces un poco.

Esto no diferenciará el DQL ni la lógica del Repositorio, pero sus servicios simplemente ignorarán el hecho de que está pasando un Album o un AlbumTrackReference, o un Track o un AlbumTrackReference porque ha escondido todo detrás de una interfaz:)

Espero que esto ayude!

 9
Author: Ocramius,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-06-07 23:23:49

Primero, estoy mayormente de acuerdo con beberlei en sus sugerencias. Sin embargo, usted puede estar diseñando usted mismo 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 para el 99% de los escenarios que encuentre. Sin embargo, what if Battery en Master of the Puppets es una versión diferente (diferente duración, en vivo, acústica, remix, remasterizado, etc.) que la versión en The Metallica Collection.

Dependiendo sobre cómo quieres manejar (o ignorar) ese caso, puedes ir a la ruta sugerida por beberlei, o simplemente ir con tu lógica adicional propuesta en Album::getTracklist (). Personalmente, creo que la lógica adicional está justificada para mantener su API limpia, pero ambas tienen su mérito.

Si desea acomodar mi caso de uso, podría hacer que las pistas contengan una OneToMany auto referenciada a otras Pistas, posiblementeTrac similarTracks. En este caso, habría dos entidades para la pista Batería, una para La Colección Metallica y otra para El Maestro de los Títeres . Luego, cada entidad de pista similar contendría una referencia entre sí. Además, eso eliminaría la clase AlbumTrackReference actual 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 anteriormente no era capaz de.

 7
Author: jsuggs,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2010-09-29 21:50:07

Usted pide la "mejor manera", pero no hay una mejor manera. Hay muchas maneras y ya has descubierto algunas de ellas. Cómo desea administrar y / o encapsular la administración de asociaciones cuando se utilizan clases de asociación depende completamente de usted y su dominio concreto, me temo que nadie puede mostrarle una "mejor manera".

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

 6
Author: romanb,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2010-08-25 18:59:41

Estaba saliendo de un conflicto con una 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 asignación en dos entidades con una relación directa de muchos a muchos parecían dar lugar a la creación automática de la tabla de unión utilizando la anotación 'JoinTable'. Sin embargo, la tabla join ya estaba definida por una anotación en su clase de entidad subyacente y quería que usara esto definición de campos propios de la 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 anteriormente. En mi situación, fue gracias a este post en el foro 'Doctrine Annotation Question'. Este post llama la atención sobre la documentación Doctrinal con respecto a Muchas relaciones unidireccionales. Mire la nota con respecto al enfoque de usar una 'clase de entidad de asociación' reemplazando la asignación de anotaciones de muchos a muchos directamente entre dos clases de entidad principales con una anotación de uno a muchos en las clases de entidad principales y dos anotaciones de "muchos a uno" en la clase de entidad asociativa. Hay un ejemplo proporcionado en este post 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;

}
 5
Author: Ben,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2013-10-03 13:57:15

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

Muchas gracias.

Para los proxies se pueden hacer funciones:

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;
 3
Author: Anthony,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2010-10-29 00:26:45

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 averiguarlo. Es demasiada información para publicar aquí, pero a continuación hay dos enlaces que puede encontrar útiles. Hacen referencia al framework Symfony, pero se basan en la Doctrina OR.

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!

 3
Author: Mike Purcell,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2011-11-03 21:20:23

La solución está en la documentación de la Doctrina. En las FAQ puedes 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

Así que ya no haces un manyToMany sino que tienes que crear una Entidad extra y poner manyToOne a tus dos entidades.

AÑADIR para @f00bar comentario:

Es simple, solo tienes que hacer algo como esto :

Article  1--N  ArticleTag  N--1  Tag

Para crear 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

 3
Author: Mirza Selimovic,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-03-23 13:53:27

Unidireccional. Simplemente agregue el inversedBy: (Nombre de columna Extranjero) 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.

 3
Author: Gatunox,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-05-27 03:40:58

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

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

Y getTrackList() contendrían AlbumTrack objetos que luego podrías usar como quieras:

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 sufre en términos de rendimiento.

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

 2
Author: rojoca,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2010-09-07 18:28:31

Mientras obtiene todas las pistas del álbum dentro de la clase album, generará una consulta más para un registro más. Eso es debido al método proxy. Hay otro ejemplo de mi código (ver último mensaje 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 unión una mejor solución?

 0
Author: quba,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2011-11-03 22:08:16

Aquí está la solución tal como se describe en la Documentación de la Doctrina2

<?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
Author: medunes,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-31 13:29:57