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