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


мне интересно, какой самый лучший, самый чистый и самый простой способ работы со многими-ко-многим отношениями в Доктрине2.

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

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

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

    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     EntityAlbum 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) %sn", 
            $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 266

14 ответов:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

объяснение и решение-это то, что определено fmaz008 выше. В моей ситуации это было благодаря этому посту на форуме'Вопрос Аннотации Доктрины'. Этот пост привлекает внимание к документации доктрины относительно многие однонаправленные отношения. Посмотрите на примечание относительно подхода использования ' ассоциации класс сущностей", таким образом, заменяя сопоставление аннотаций "многие ко многим" непосредственно между двумя основными классами сущностей аннотацией "один ко многим" в основных классах сущностей и двумя аннотациями "многие ко многим" в ассоциативном классе сущностей. Есть пример, приведенный в этом посте форума ассоциативные модели с дополнительными полями:

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;

}

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

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

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!

решение находится в документации доктрины. В 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 to ваши две сущности.

добавить для @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

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

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

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

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

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

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

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

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

вот решение, как описано в Doctrine2 Documentation

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