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

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

Предположим, что у нас есть альбом типа Мастер кукол 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();
 }

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

Ответ 1

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

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

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

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

Ответ 2

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

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

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

Ответ 3

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

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

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

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

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

Ответ 4

Я думаю, что я пойду с предложением @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, потому что вы 'скрыл все за интерфейсом:)

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

Ответ 5

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

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

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

Ответ 6

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

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

Ответ 7

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

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

Объяснение и решение таковы, что определено FMaz008 выше. В моей ситуации именно благодаря этому сообщению на форуме "" Вопрос аннотации доктрины ". В этом сообщении обращается внимание на документацию Doctrine относительно ManyToMany Uni-directional relations. Посмотрите на примечание относительно подхода использования" класса сущности ассоциации", таким образом, заменив сопоставление аннотаций "многие-ко-многим" непосредственно между двумя основными классами сущностей с аннотацией "один-ко-многим "в основных классах сущностей и двумя" много- -one 'в классе ассоциативной сущности. В этом форуме приведен пример, представленный Модели с дополнительными полями:

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;

}

Ответ 8

Этот действительно полезный пример. В документации доктрины 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;

Ответ 9

То, что вы имеете в виду, это метаданные, данные о данных. У меня была такая же проблема для проекта, над которым я сейчас работаю, и мне пришлось потратить некоторое время, пытаясь понять это. Это слишком много информации для публикации здесь, но ниже - две ссылки, которые вы можете найти полезными. Они ссылаются на структуру Symfony, но основываются на ORM Doctrine.

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!

Ответ 10

Решение находится в документации Doctrine. В 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, но вам нужно создать дополнительную Entity и поместить 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

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

Ответ 11

однонаправленный. Просто добавьте 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

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

Ответ 12

Вы можете достичь того, чего хотите, Наследование классов, в котором вы меняете 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!' : ''
    );
}

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

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

Ответ 13

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

Есть ли какой-либо другой метод для решения этого? Не единственное соединение - лучшее решение?

Ответ 14

Вот решение, описанное в Документация 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();
    }
}