Doctrine2: Melhor maneira de lidar com muitos para muitos com colunas extras na tabela de referência

282

Pergunto-me qual é a melhor, a maneira mais limpa e mais simples de trabalhar com relações muitos-para-muitos no Doctrine2.

Vamos supor que temos um álbum como o Master of Puppets do Metallica com várias faixas. Mas observe o fato de que uma faixa pode aparecer em mais de um álbum, como o Battery by Metallica - três álbuns estão apresentando essa faixa.

Então, o que eu preciso é de um relacionamento muitos para muitos entre álbuns e faixas, usando a terceira tabela com algumas colunas adicionais (como a posição da faixa no álbum especificado). Na verdade, tenho que usar, como sugere a documentação de Doctrine, uma dupla relação um-para-muitos para alcançar essa funcionalidade.

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

Dados de amostra:

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

Agora eu posso exibir uma lista de álbuns e faixas associadas a eles:

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

Os resultados são o que eu estou esperando, ou seja: uma lista de álbuns com suas faixas na ordem apropriada e os promovidos sendo marcados como promovidos.

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) 

Então, oque há de errado?

Este código demonstra o que está errado:

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

Album::getTracklist()devolve uma disposição de AlbumTrackReferenceobjectos em vez de Trackobjectos. Eu não posso criar métodos de proxy porque e se ambos, Albume Trackteria getTitle()método? Eu poderia fazer algum processamento extra dentro do Album::getTracklist()método, mas qual é a maneira mais simples de fazer isso? Sou forçado a escrever algo assim?

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 sugeriu usar métodos de proxy:

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

Isso seria uma boa idéia, mas eu estou usando esse "objeto de referência" de ambos os lados: $album->getTracklist()[12]->getTitle()e $track->getAlbums()[1]->getTitle(), por isso getTitle()método deve retornar dados diferentes com base no contexto de invocação.

Eu teria que fazer 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();
 }

E essa não é uma maneira muito limpa.

Crozin
fonte
2
Como você lida com o AlbumTrackReference? Por exemplo $ album-> addTrack () ou $ album-> removeTrack ()?
21713 Daniel
Não entendi o que você comentou sobre o contexto. Na minha opinião, os dados não dependem do contexto. About $album->getTracklist()[12]é AlbumTrackRefobject, portanto $album->getTracklist()[12]->getTitle(), sempre retornará o título da faixa (se você estiver usando o método proxy). Enquanto $track->getAlbums()[1]é Albumobjeto, $track->getAlbums()[1]->getTitle()sempre retornará o título do álbum.
Vinícius Fagundes
Outra idéia é usar AlbumTrackReferencedois métodos de proxy, getTrackTitle()e getAlbumTitle.
Vinícius Fagundes

Respostas:

158

Abri uma pergunta semelhante na lista de discussão de usuários do Doctrine e recebi uma resposta muito simples;

considere a relação muitos para muitos como uma entidade em si, e então você percebe que possui três objetos, vinculados entre eles com uma relação um para muitos e muitos para um.

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

Uma vez que uma relação tenha dados, não será mais uma relação!

FMaz008
fonte
Alguém sabe como posso obter a ferramenta de linha de comando da doutrina para gerar essa nova entidade como um arquivo de esquema yml? Este comando: app/console doctrine:mapping:import AppBundle ymlainda geram relação ManyToMany para as duas tabelas originais e simplesmente ignorar a terceira tabela, em vez de concidering-lo como uma entidade:/
Stphane
qual é a diferença entre foreach ($album->getTracklist() as $track) { echo $track->getTrack()->getTitle(); }fornecido pelo @Crozin e consider the relationship as an entity? Eu acho que o que ele quer fazer é como pular a entidade relacional e recuperar o título de uma faixa utilizandoforeach ($album->getTracklist() as $track) { echo $track->getTitle(); }
panda
6
"Uma vez que uma relação tenha dados, não haverá mais relação" Isso foi realmente esclarecedor. Eu simplesmente não conseguia pensar em uma relação da perspectiva de uma entidade!
Cebola
E se o relacionamento já tiver sido criado e usado como muitos para muitos. Percebemos que precisávamos de campos extras entre muitos e muitos, então criamos uma entidade diferente. O problema é que, com dados existentes e uma tabela existente com o mesmo nome, parece que não queremos ser amigos. Alguém já tentou isso antes?
Tylerism
Para aqueles que se perguntam: criar uma Entidade com a articulação muitos-para-muitos (já existente) à medida que sua tabela funciona, no entanto, as entidades que mantêm a muitos-para-muitos devem ser adaptadas para um-para-muitos para a nova entidade. também interfaces para o exterior (getters / setters para o antigo muitos-para-muitos) provavelmente têm que ser adaptadas.
Jakumi 18/09/19
17

Em $ album-> getTrackList (), você também recebe entidades "AlbumTrackReference" de volta. E quanto a adicionar métodos da faixa e do proxy?

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

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

Dessa forma, seu loop simplifica consideravelmente, assim como todos os outros códigos relacionados ao loop das faixas de um álbum, já que todos os métodos são submetidos a proxy dentro do 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!' : ''
    );
}

Você deve renomear o AlbumTrackReference (por exemplo "AlbumTrack"). É claramente não apenas uma referência, mas contém lógica adicional. Como provavelmente existem também faixas que não estão conectadas a um álbum, mas disponíveis apenas através de um CD promocional ou algo que permita uma separação mais limpa também.

beberlei
fonte
1
Os métodos de proxy não resolvem o problema em 100% (verifique minha edição). Btw You should rename the AlbumT(...)- bom ponto
Crozin
3
Por que você não tem dois métodos? getAlbumTitle () e getTrackTitle () no objeto AlbumTrackReference? Ambos proxy para seus respectivos subobjetos.
beblei 26/08
O objetivo é a API de objeto mais natural . $album->getTracklist()[1]->getTrackTitle()é tão bom / ruim quanto $album->getTracklist()[1]->getTrack()->getTitle(). No entanto, parece que eu teria que ter duas classes diferentes: uma para referências de álbuns -> faixas e outra para referências de faixas -> álbuns - e isso é muito difícil de implementar. Então, provavelmente essa é a melhor solução até agora ...
Crozin
13

Nada supera um bom exemplo

Para pessoas que procuram um exemplo de codificação limpo de associações um-para-muitos / muitos-para-um entre as três classes participantes para armazenar atributos extras na relação, consulte este site:

bom exemplo de associações um-para-muitos / muitos-para-um entre as três classes participantes

Pense nas suas chaves primárias

Pense também na sua chave primária. Muitas vezes você pode usar chaves compostas para relacionamentos como este. A doutrina apoia nativamente isso. Você pode transformar suas entidades referenciadas em IDs. Verifique a documentação sobre chaves compostas aqui

Wilt
fonte
10

Eu acho que iria com a sugestão do @ beberlei de usar métodos de proxy. O que você pode fazer para simplificar esse processo é definir duas interfaces:

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

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

Em seguida, você Albume seu Trackpodem implementá-los, enquanto o AlbumTrackReferenceainda pode implementar ambos, da seguinte maneira:

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

Dessa forma, removendo sua lógica que está referenciando diretamente um Trackou um Albume apenas substituindo-o para que ele use um TrackInterfaceou AlbumInterface, você poderá usá-lo AlbumTrackReferenceem qualquer caso possível. O que você precisará é diferenciar um pouco os métodos entre as interfaces.

Isso não diferencia o DQL nem a lógica do repositório, mas seus serviços apenas ignoram o fato de que você está passando um Albumou um AlbumTrackReference, ou um Trackou um AlbumTrackReferenceporque escondeu tudo por trás de uma interface :)

Espero que isto ajude!

Ocramius
fonte
7

Primeiro, concordo principalmente com beberlei em suas sugestões. No entanto, você pode estar se projetando em uma armadilha. Seu domínio parece considerar o título a chave natural de uma faixa, o que provavelmente ocorre em 99% dos cenários que você encontra. No entanto, e se o Battery on Master of the Puppets for uma versão diferente (duração diferente, ao vivo, acústica, remix, remasterizada etc.) que a versão do The Metallica Collection .

Dependendo de como você deseja lidar (ou ignorar) esse caso, você pode seguir a rota sugerida de beberlei ou apenas seguir sua lógica extra proposta em Album :: getTracklist (). Pessoalmente, acho que a lógica extra é justificada para manter sua API limpa, mas ambas têm seu mérito.

Se você deseja acomodar meu caso de uso, o Tracks pode conter um OneToMany auto-referente a outros Tracks, possivelmente $ similarTracks. Nesse caso, haveria duas entidades para a faixa Battery , uma para a coleção Metallica e outra para o Master of the Puppets . Então, cada entidade Track similar conteria uma referência uma à outra. Além disso, isso eliminaria a classe AlbumTrackReference atual e eliminaria o seu "problema" atual. Concordo que ele está apenas movendo a complexidade para um ponto diferente, mas é capaz de lidar com um caso de uso que não era capaz anteriormente.

jsuggs
fonte
6

Você pede o "melhor caminho", mas não há o melhor. Existem muitas maneiras e você já descobriu algumas delas. Como você deseja gerenciar e / ou encapsular o gerenciamento de associação ao usar classes de associação depende inteiramente de você e do seu domínio concreto, ninguém pode mostrar o "melhor caminho", receio.

Além disso, a questão poderia ser muito simplificada, removendo o Doctrine e os bancos de dados relacionais da equação. A essência da sua pergunta se resume a uma pergunta sobre como lidar com classes de associação na OOP simples.

romanb
fonte
6

Eu estava obtendo um conflito com a tabela de junção definida em uma anotação de classe de associação (com campos personalizados adicionais) e uma tabela de junção definida em uma anotação de muitos para muitos.

As definições de mapeamento em duas entidades com um relacionamento direto de muitos para muitos pareciam resultar na criação automática da tabela de junção usando a anotação 'joinTable'. No entanto, a tabela de junção já foi definida por uma anotação em sua classe de entidade subjacente e eu queria que ela usasse as próprias definições de campo dessa classe de entidade de associação para estender a tabela de junção com campos personalizados adicionais.

A explicação e solução é aquela identificada pelo FMaz008 acima. Na minha situação, foi graças a este post no fórum ' Doctrine Annotation Question '. Este post chama a atenção para a documentação do Doutrine sobre relacionamentos unidirecionais ManyToMany . Observe a observação sobre a abordagem do uso de uma 'classe de entidade de associação', substituindo, assim, o mapeamento de anotação muitos-para-muitos diretamente entre duas classes principais de entidades por uma anotação um-para-muitos nas principais classes de entidade e dois 'muitos-para-muitos' -um 'anotações na classe de entidade associativa. Há um exemplo fornecido nesta postagem no fórum Modelos de associação com campos extras :

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

Este exemplo realmente útil. Falta na doutrina da documentação 2.

Muito obrigado.

Para os proxies, funções podem ser feitas:

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;
Anthony
fonte
3

Você está se referindo a metadados, dados sobre dados. Eu tive esse mesmo problema no projeto em que estou trabalhando no momento e tive que gastar algum tempo tentando descobrir isso. É muita informação para postar aqui, mas abaixo estão dois links que você pode achar úteis. Eles fazem referência à estrutura do Symfony, mas são baseados no 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/

Boa sorte e boas referências ao Metallica!

Mike Purcell
fonte
3

A solução está na documentação do Doctrine. Nas perguntas frequentes, você pode ver isso:

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

E o tutorial está aqui:

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

Portanto, você não faz mais um, manyToManymas precisa criar uma Entidade extra e colocar manyToOneem suas duas entidades.

ADICIONAR para @ f00bar comentário:

é simples, você só precisa fazer algo assim:

Article  1--N  ArticleTag  N--1  Tag

Então você cria uma entidade 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 ajude

Mirza Selimovic
fonte
1
Links atualizados: 1 - FAQ , 2 - Tutoriais: Chaves primárias compostas
m14t 4/15/15
Exatamente o que eu estava procurando, obrigado! Infelizmente, não há exemplo de yml para o terceiro caso de uso! :(Alguém poderia compartilhar um exemplo do terceiro caso de uso usando o formato yml? Eu realmente appriace:#
Stphane
eu adicionei para a resposta o seu caso;)
Mirza Selimovic
Está incorreto. A entidade não precisa estar com o ID (ID) AUTO. Isso está errado, estou tentando criar o exemplo correto
Gatunox 27/05
vou postar uma nova resposta para se se formatado corretamente
Gatunox
3

Unidirecional. Basta adicionar o inversedBy: (nome da coluna estrangeira) para torná-lo bidirecional.

# 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 ajude. Até logo.

Gatunox
fonte
2

Você pode conseguir o que deseja com a Herança de tabela de classe, onde altera AlbumTrackReference para AlbumTrack:

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

E getTrackList()conteria AlbumTrackobjetos que você poderia usar como quiser:

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

Você precisará examinar isso completamente para garantir que não sofra desempenho.

Sua configuração atual é simples, eficiente e fácil de entender, mesmo que algumas das semânticas não pareçam bem com você.

rojoca
fonte
0

Ao obter todas as faixas do álbum dentro da classe, você gerará mais uma consulta para mais um registro. Isso é por causa do método proxy. Há outro exemplo do meu código (consulte a última postagem no tópico): http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868

Existe algum outro método para resolver isso? Uma única junção não é uma solução melhor?

quba
fonte
1
Embora isso possa teoricamente responder à pergunta, seria preferível incluir aqui as partes essenciais da resposta e fornecer o link para referência.
Spontifixus 7/11
0

Aqui está a solução, conforme descrito na Documentação do 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
fonte