Мне интересно, какой лучший, самый чистый и самый простой способ работать со многими отношениями в Doctrine2.
Предположим, что у нас есть альбом типа Master of Puppets от Metallica с несколькими треками. Но учтите тот факт, что один трек может появиться в более чем одном альбоме, например Battery by 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 \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(); }
И это не очень чистый путь.
Я открыл аналогичный вопрос в списке рассылки пользователей Doctrine и получил очень простой ответ;
рассмотрите отношение многих ко многим как сущность, а затем вы поймете, что у вас есть 3 объекта, связанных между собой соотношением «один ко многим» и «много-к-одному».
Как только отношение имеет данные, это уже не отношение!
Из $ 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 участвующими классами
Подумайте о своих основных ключах
Также подумайте о своем первичном ключе. Вы часто можете использовать составные клавиши для таких отношений. Доктрина изначально поддерживает это. Вы можете сделать свои ссылочные объекты в идентификаторы. Проверьте документацию по составным клавишам здесь
Я думаю, что я пойду с предложением @ Beberlei использовать прокси-методы. Что вы можете сделать, чтобы упростить этот процесс, так это определить два интерфейса:
interface AlbumInterface { public function getAlbumTitle(); public function getTracklist(); } interface TrackInterface { public function getTrackTitle(); public function getTrackDuration(); }
Затем оба Album
и ваш Track
могут их реализовать, в то время как AlbumTrackReference
все еще может реализовать оба 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% сценариев, с которыми вы сталкиваетесь. Однако, если Батарея на Master of the Puppets – это другая версия (разная длина, живая, акустическая, ремиксная, ремастированная и т. Д.), Чем версия The Metallica Collection .
В зависимости от того, как вы хотите обрабатывать (или игнорировать) этот случай, вы можете либо пойти по предложенному маршрутом беберлей, либо просто пойти с предложенной дополнительной логикой в Album :: getTracklist (). Лично я считаю, что дополнительная логика оправдана для того, чтобы ваш API был чистым, но оба имеют свои достоинства.
Если вы хотите разместить мой вариант использования, у вас могут быть треки, содержащие самонаводящиеся OneToMany на другие треки, возможно $ SimilarTracks. В этом случае было бы два объекта для аккумулятора трека, один для The Metallica Collection и один для Master of the Puppets . Затем каждый подобный объект Track будет содержать ссылку друг на друга. Кроме того, это избавит вас от текущего класса AlbumTrackReference и устранит вашу текущую проблему. Я согласен с тем, что это просто переводит сложность в другую точку, но она способна обрабатывать usecase, которой она ранее не могла.
Вы просите «лучший способ», но лучшего способа нет. Есть много способов, и вы уже обнаружили некоторые из них. Как вы хотите управлять и / или инкапсулировать управление ассоциациями при использовании классов ассоциации, полностью зависит от вас и вашего конкретного домена, никто не может показать вам «лучший способ», я боюсь.
Кроме того, вопрос можно было бы упростить, удалив из уравнения Доктрину и реляционные базы данных. Суть вашего вопроса сводится к вопросу о том, как обращаться с классами ассоциаций в ООП.
Я получал от конфликта с таблицей соединений, определенной в аннотации класса ассоциации (с дополнительными настраиваемыми полями) и таблицей соединений, определенной в аннотации «многие-ко-многим».
Определения отображения в двух объектах с прямым отношением «многие ко многим», как представляется, привели к автоматическому созданию таблицы соединений с помощью аннотации «joinTable». Однако таблица соединений уже была определена аннотацией в его базовом классе сущностей, и я хотел, чтобы он использовал собственные определения полей класса ассоциации, чтобы расширить таблицу соединений с дополнительными настраиваемыми полями.
Объяснение и решение таковы, что определено FMaz008 выше. В моей ситуации это было благодаря этой должности в форуме « Вопрос аннотации доктрины ». В этом сообщении обращается внимание на документацию Doctrine, касающуюся однонаправленных отношений ManyToMany . Посмотрите на примечание относительно подхода использования «класса сущности ассоциации», таким образом, заменив сопоставление аннотаций «многие-ко-многим» непосредственно между двумя основными классами сущностей с аннотацией «один-ко-многим» в основных классах сущностей и двумя «много- -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; }
Это действительно полезный пример. В доктрине документации 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, но основываются на 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!
Решение находится в документации Doctrine. В FAQ вы можете увидеть это:
И учебник здесь:
http://docs.doctrine-project.org/en/2.1/tutorials/composite-primary-keys.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
Я надеюсь, что это помогает
Однонаправленный. Просто добавьте 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
Я надеюсь, что это помогает. Увидимся.
Вы можете добиться того, чего хотите, с помощью Class Table Inheritance, где вы меняете 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
<?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(); } }