Doctrine2: лучший способ обрабатывать многие ко многим с помощью дополнительных столбцов в справочной таблице - PullRequest
274 голосов
/ 22 августа 2010

Мне интересно, какой самый лучший, самый чистый и самый простой способ работать с отношениями «многие ко многим» в Doctrine2.

Давайте предположим, что у нас есть альбом типа Master of Puppets от Metallica с несколькими треками. Но обратите внимание на тот факт, что один трек может появляться в нескольких альбомах, как, например, Battery от Metallica - три альбома содержат этот трек.

Итак, мне нужны отношения «многие ко многим» между альбомами и треками, используя третью таблицу с некоторыми дополнительными столбцами (например, положение трека в указанном альбоме). На самом деле мне нужно использовать, как показывает документация Doctrine, двойное отношение один-ко-многим для достижения этой функциональности.

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

Пример данных:

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

Теперь я могу отобразить список альбомов и связанных с ними треков:

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

Результаты - это то, чего я ожидаю, а именно: список альбомов с их треками в соответствующем порядке и продвигаемыми, помеченными как продвинутые.

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) 

Так что не так?

Этот код демонстрирует, что не так:

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

Album::getTracklist() возвращает массив объектов AlbumTrackReference вместо Track объектов. Я не могу создать прокси-методы, потому что, если оба, Album и Track будут иметь getTitle() метод? Я мог бы выполнить дополнительную обработку в методе Album::getTracklist(), но как проще всего это сделать? Я вынужден написать что-то подобное?

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

EDIT

@ beberlei предложил использовать прокси-методы:

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

Это было бы неплохо, но я использую этот «объект ссылки» с обеих сторон: $album->getTracklist()[12]->getTitle() и $track->getAlbums()[1]->getTitle(), поэтому метод getTitle() должен возвращать разные данные в зависимости от контекста вызова.

Я бы сделал что-то вроде:

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

И это не очень чистый способ.

Ответы [ 14 ]

156 голосов
/ 02 ноября 2011

Я открыл похожий вопрос в списке рассылки пользователей Doctrine и получил очень простой ответ;

рассматривайте отношение «многие ко многим» как саму сущность, и тогда вы понимаете, что имеете 3 объекта, связанных между собой отношением «один ко многим» и «многие к одному».

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

Как только отношение имеет данные, оно больше не является отношением!

17 голосов
/ 25 августа 2010

Из $ album-> getTrackList () вы всегда получите обратно объекты «AlbumTrackReference», так как насчет добавления методов из Track и прокси?

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

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

Таким образом, ваш цикл значительно упрощается, также как и весь другой код, связанный с циклическим воспроизведением дорожек альбома, поскольку все методы просто проксируются внутри 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!' : ''
    );
}

Кстати, вы должны переименовать AlbumTrackReference (например, «AlbumTrack»). Это явно не только ссылка, но и дополнительная логика. Поскольку, вероятно, есть также треки, которые не связаны с альбомом, а просто доступны через промо-диск или что-то еще, это также позволяет более четко разделить.

13 голосов
/ 05 апреля 2013

Ничто не сравнится с хорошим примером

Для людей, которые ищут чистый пример кодирования связей один-ко-многим / многие-к-одному между 3 участвующими классами для храненияатрибуты в связи проверяют этот сайт:

хороший пример связей один-ко-многим / многие-к-одному между 3 участвующими классами

Подумайте о своих первичных ключах

Также подумайте о своем первичном ключе.Вы можете часто использовать составные ключи для таких отношений.Доктрина изначально поддерживает это.Вы можете превратить ваши ссылочные объекты в идентификаторы. Проверьте документацию по составным ключам здесь

10 голосов
/ 08 июня 2012

Думаю, я бы согласился с предложением @ beberlei использовать прокси-методы. Чтобы упростить этот процесс, вы можете определить два интерфейса:

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

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

Тогда и ваш Album, и ваш Track могут реализовать их, в то время как AlbumTrackReference может реализовать оба, как показано ниже:

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

Таким образом, удаляя вашу логику, которая непосредственно ссылается на Track или Album, и просто заменяя ее так, чтобы она использовала TrackInterface или AlbumInterface, вы можете использовать свой AlbumTrackReference в любой возможный случай. То, что вам нужно, это немного различать методы между интерфейсами.

Это не будет дифференцировать ни DQL, ни логику репозитория, но ваши службы будут просто игнорировать тот факт, что вы передаете Album или AlbumTrackReference, или Track, или AlbumTrackReference, потому что вы спрятал все за интерфейсом:)

Надеюсь, это поможет!

7 голосов
/ 30 сентября 2010

Во-первых, я в основном согласен с Беберлей в его предложениях.Тем не менее, вы можете загнать себя в ловушку.Похоже, что ваш домен рассматривает заголовок как естественный ключ для трека, что, вероятно, имеет место в 99% сценариев, с которыми вы сталкиваетесь.Однако, что если Батарея на Мастер кукол - это другая версия (другая длина, живая, акустическая, ремикс, ремастеринг и т. Д.), Чем версия на Коллекция Metallica.

В зависимости от того, как вы хотите обработать (или игнорировать) этот случай, вы можете либо пойти по предложенному Беберли маршруту, либо просто пойти с предложенной дополнительной логикой в ​​Album :: getTracklist ().Лично я считаю, что дополнительная логика оправдана, чтобы поддерживать ваш API в чистоте, но оба имеют свои достоинства.

Если вы действительно хотите приспособить мой вариант использования, вы можете иметь дорожки, содержащие собственную ссылку OneToMany на другие дорожки,возможно $ SimilarTracks.В этом случае будет два объекта для дорожки Аккумулятор , один для Коллекция Metallica и один для Мастер кукол .Тогда каждая похожая сущность Track будет содержать ссылку друг на друга.Кроме того, это избавит от текущего класса AlbumTrackReference и устранит текущую «проблему».Я согласен с тем, что он просто переносит сложность в другую точку, но он способен обрабатывать сценарий использования, которого раньше не мог.

6 голосов
/ 03 октября 2013

Я получал конфликт с таблицей соединений, определенной в аннотации класса ассоциации (с дополнительными настраиваемыми полями), и таблицей соединений, определенной в аннотации "многие ко многим".

Определения сопоставления в двух объектахпрямое отношение «многие ко многим», по-видимому, приводит к автоматическому созданию таблицы соединения с использованием аннотации «joinTable».Однако таблица соединения уже была определена аннотацией в базовом классе сущности, и я хотел, чтобы она использовала собственные определения полей этого класса сущности ассоциации, чтобы расширить таблицу соединения дополнительными настраиваемыми полями.

Объяснение и решениеэто то, что определено FMaz008 выше.В моей ситуации это было благодаря этому посту на форуме ' Doctrine Annotation Question '.Этот пост привлекает внимание к документации Доктрины, касающейся однонаправленных связей ManyToMany .Посмотрите на примечание, касающееся подхода использования «класса сущностей ассоциации», таким образом заменяя отображение аннотации «многие ко многим» непосредственно между двумя основными классами сущностей аннотацией «один ко многим» в основных классах сущностей и двумя «многими к»-он 'аннотации в классе ассоциативного объекта.В этом сообщении на форуме есть пример Ассоциация моделей с дополнительными полями :

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;

}
6 голосов
/ 25 августа 2010

Вы просите «лучший путь», но лучшего пути нет. Есть много способов, и вы уже обнаружили некоторые из них. Как вы хотите управлять и / или инкапсулировать управление ассоциациями, когда использование классов ассоциаций полностью зависит от вас и вашего конкретного домена, боюсь, никто не может показать вам «лучший способ».

Кроме того, вопрос можно было бы значительно упростить, исключив из уравнения доктрина и реляционные базы данных. Суть вашего вопроса сводится к вопросу о том, как обращаться с классами ассоциаций в простом ООП.

3 голосов
/ 27 мая 2015

однонаправленная.Просто добавьте inversedBy: (Foreign Column Name), чтобы сделать его двунаправленным.

# 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

Надеюсь, это поможет.Увидимся.

3 голосов
/ 15 января 2014

Решение есть в документации Доктрины.В FAQ вы можете увидеть это:

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

И учебное пособие здесь:

http://docs.doctrine -project.org / ru / 2.1 / tutorials / составные-первичные-ключи.html

Таким образом, вы больше не делаете manyToMany, но вам нужно создать дополнительную сущность и поставить manyToOne для ваших двух сущностей.

ADD для @ f00bar комментария:

это просто, вам нужно просто сделать что-то вроде этого:

Article  1--N  ArticleTag  N--1  Tag

Итак, вы создаете сущность 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

Iнадеюсь, это поможет

3 голосов
/ 04 ноября 2011

То, что вы имеете в виду, это метаданные, данные о данных. У меня была такая же проблема для проекта, над которым я сейчас работаю, и мне пришлось потратить некоторое время, пытаясь понять это. Здесь слишком много информации для размещения, но ниже приведены две ссылки, которые могут оказаться полезными. Они ссылаются на платформу Symfony, но основаны на доктрине 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/

Удачи и хороших ссылок Metallica!

...