我想知道在Doctrine2中处理多对多关系最好、最干净、最简单的方法是什么。

让我们假设我们有一张像Metallica的Master of Puppets这样的专辑,里面有几首歌。但请注意,这首歌可能会出现在不止一张专辑中,就像金属乐队的《Battery》——三张专辑都有这首歌。

因此,我需要的是专辑和曲目之间的多对多关系,使用带有一些附加列的第三个表(如指定专辑中的曲目位置)。实际上,正如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对象。我不能创建代理方法因为如果专辑和轨道都有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();
 }

这不是一个很干净的方法。


当前回答

没有什么比一个好的例子更好的了

对于想要在3个参与类之间存储额外属性的一对多/多对一关联的简洁代码示例的人,请查看这个网站:

3个参与类之间一对多/多对一关联的好例子

考虑一下主键

还要考虑一下主键。对于这样的关系,通常可以使用复合键。教义本身就支持这一点。您可以将引用的实体转换为id。 在这里查看关于复合键的文档

其他回答

首先,我基本同意beberlei的建议。然而,你可能把自己设计进了陷阱。您的域名似乎认为标题是音轨的自然键,这可能是您遇到的99%的场景的情况。然而,如果Master of the Puppets上的Battery是一个不同的版本(不同的长度、现场、原声、混音、重制版等),而不是the Metallica Collection上的版本呢?

根据您想要如何处理(或忽略)这种情况,您可以选择beberlei建议的路线,或者只是使用Album::getTracklist()中建议的额外逻辑。就我个人而言,我认为额外的逻辑是合理的,以保持API的简洁,但两者都有各自的优点。

If you do wish to accommodate my use case, you could have Tracks contain a self referencing OneToMany to other Tracks, possibly $similarTracks. In this case, there would be two entities for the track Battery, one for The Metallica Collection and one for Master of the Puppets. Then each similar Track entity would contain a reference to each other. Also, that would get rid of the current AlbumTrackReference class and eliminate your current "issue". I do agree that it is just moving the complexity to a different point, but it is able to handle a usecase it wasn't previously able to.

你可以通过类表继承来实现你想要的,你可以把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!' : ''
    );
}

您需要彻底检查这一点,以确保不会在性能方面受到影响。

您当前的设置简单、高效且易于理解,即使有些语义不太适合您。

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

解决方案在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放到你的两个实体中。

添加@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

我希望这对你们有帮助

我在Doctrine用户邮件列表中打开了一个类似的问题,得到了一个非常简单的答案;

将多对多关系视为一个实体本身,然后您会意识到您有3个对象,它们之间通过一对多和多对一的关系进行链接。

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

一旦关系有了数据,它就不再是关系了!