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

282

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

Давайте предположим, что у нас есть альбом типа Master of Puppets от Metallica с несколькими треками. Но, пожалуйста, обратите внимание на тот факт, что один трек может появиться в более чем одном альбоме, как в Battery by 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

РЕДАКТИРОВАТЬ

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

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

Crozin
источник
2
Как вы справляетесь с AlbumTrackReference? Например, $ album-> addTrack () или $ album-> removeTrack ()?
Даниил
Я не поняла, что вы комментируете контекст. По моему мнению, данные не зависят от контекста. About $album->getTracklist()[12]is AlbumTrackRefobject, поэтому $album->getTracklist()[12]->getTitle()всегда будет возвращать заголовок трека (если вы используете прокси-метод). Пока $track->getAlbums()[1]является Albumобъектом, поэтому $track->getAlbums()[1]->getTitle()всегда будет возвращать название альбома.
Виниций Фагундес
Другая идея заключается в использовании AlbumTrackReferenceдвух прокси-методов getTrackTitle()и getAlbumTitle.
Виниций Фагундес

Ответы:

158

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

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

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

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

FMaz008
источник
Кто-нибудь знает, как я могу получить инструмент командной строки doctrine для создания этого нового объекта в виде файла схемы yml? Эта команда: app/console doctrine:mapping:import AppBundle ymlвсе еще генерирует отношение manyToMany для исходных двух таблиц и просто игнорирует третью таблицу вместо того, чтобы рассматривать ее как сущность:/
Stphane
Чем отличается foreach ($album->getTracklist() as $track) { echo $track->getTrack()->getTitle(); }@Crozin от consider the relationship as an entity? Я думаю, что он хочет спросить, как пропустить реляционную сущность и получить название трека с помощьюforeach ($album->getTracklist() as $track) { echo $track->getTitle(); }
panda
6
«Как только отношение имеет данные, оно больше не является отношением». Это было действительно поучительно. Я просто не мог думать об отношениях с точки зрения сущности!
Лук
А что если отношения уже созданы и используются как многие ко многим. Мы поняли, что нам нужны дополнительные поля в наших многих для многих, поэтому мы создали другую сущность. Проблема в том, что с существующими данными и существующей таблицей с тем же именем она не хочет дружить. Кто-нибудь пробовал это раньше?
Тайлеризм
Для тех, кто задается вопросом: создание сущности с (уже существующей) объединяемой таблицей «многие ко многим», как работает ее таблица, однако, сущности, содержащие «многие ко многим», должны быть адаптированы вместо «один ко многим» для новой сущности. также, скорее всего, должны быть адаптированы внешние интерфейсы (геттеры / сеттеры для прежних «многие ко многим»).
Якуми
17

Из $ 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»). Это явно не только ссылка, но и дополнительная логика. Так как, вероятно, есть также треки, которые не связаны с альбомом, а просто доступны через промо-диск или что-то еще, это также позволяет добиться более чистого разделения.

beberlei
источник
1
Прокси-методы не решают проблему на 100% (проверьте мое редактирование). Btw You should rename the AlbumT(...)- хорошая точка
Крозин
3
Почему у вас нет двух методов? getAlbumTitle () и getTrackTitle () для объекта AlbumTrackReference? Оба прокси для своих соответствующих подобъектов.
Беберлей
Цель - самый естественный объект API. $album->getTracklist()[1]->getTrackTitle()так же хорошо / плохо, как $album->getTracklist()[1]->getTrack()->getTitle(). Однако кажется, что мне нужно было бы иметь два разных класса: один для ссылок на альбом -> трек и другой для ссылок на трек -> альбомы - и это слишком сложно реализовать. Так что, вероятно, это лучшее решение на сегодняшний день ...
Крозин
13

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

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

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

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

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

Уилт
источник
10

Я думаю, что согласился бы с предложением @ 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();
    }
}

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

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

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

Ocramius
источник
7

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

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

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

jsuggs
источник
6

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

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

RomanB
источник
6

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

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

Объяснение и решение - то, что определено FMaz008 выше. В моей ситуации это было благодаря этому посту на форуме ' Doctrine Annotation Question '. Этот пост привлекает внимание к документации Doctrine, касающейся однонаправленных отношений 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;

}
Onshop
источник
3

Это действительно полезный пример. В документации нет доктрины 2.

Большое спасибо.

Для прокси функции могут быть выполнены:

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

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

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

и

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

/** @OneToMany(targetEntity="AlbumTrack", mappedBy="track") */
protected $albumsFeaturingThisTrack;
Энтони
источник
3

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

Майк Перселл
источник
3

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

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

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

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

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

ДОБАВИТЬ для комментария @ 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

Я надеюсь, что это помогает

Мирза Селимович
источник
Это именно то, что я искал, спасибо! К сожалению, нет примера yml для третьего варианта использования! :(Может ли кто-нибудь поделиться примером третьего варианта использования в формате yml? Я был бы очень ценен:#
Стефан
я добавил к ответу ваше дело;)
Мирза Селимович
Это неверно Сущность не обязательно должна быть с идентификатором (id) AUTO. Это неправильно, я пытаюсь создать правильный пример
Gatunox
я
отправлю
3

Однонаправленный. Просто добавьте inversedBy: (имя внешнего столбца), чтобы сделать его двунаправленным.

# 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

Я надеюсь, что это помогает. Увидимся.

Gatunox
источник
2

Вы можете достичь желаемого с помощью наследования таблиц классов, где вы измените AlbumTrackReference на AlbumTrack:

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

И getTrackList()будет содержать AlbumTrackобъекты, которые вы затем сможете использовать как хотите:

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

Вам нужно будет тщательно проверить это, чтобы убедиться, что вы не страдаете от производительности.

Ваша текущая настройка проста, эффективна и проста для понимания, даже если некоторая семантика не совсем подходит вам.

rojoca
источник
0

Получая все треки альбома в классе альбома, вы создадите еще один запрос для еще одной записи. Это из-за метода прокси. Есть еще один пример моего кода (см. Последнее сообщение в теме): http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868

Есть ли другой способ решить это? Разве одно соединение не является лучшим решением?

Губа
источник
1
Хотя это может теоретически ответить на вопрос, было бы предпочтительным включить сюда основные части ответа и предоставить ссылку для справки.
Spontifixus
0

Вот решение, описанное в документации 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
источник