我想知道在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();
 }

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


从$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”)。它显然不仅是一个引用,而且还包含额外的逻辑。因为也有可能是没有连接到专辑,但只是通过促销光盘或其他东西,这也允许一个更清晰的分离。


你要求“最好的方法”,但没有最好的方法。有很多方法,其中一些你已经发现了。在使用关联类时,你想要如何管理和/或封装关联管理完全取决于你和你的具体领域,恐怕没有人能告诉你一个“最好的方法”。

除此之外,通过从等式中删除Doctrine和关系数据库,这个问题可以简化很多。你的问题的本质可以归结为如何在纯OOP中处理关联类。


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

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

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


首先,我基本同意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.


这个例子很有用。它缺乏文件主义。

非常感谢。

对于代理功能可以做:

class AlbumTrack extends AlbumTrackAbstract {
   ... proxy method.
   function getTitle() {} 
}

class TrackAlbum extends AlbumTrackAbstract {
   ... proxy method.
   function getTitle() {}
}

class AlbumTrackAbstract {
   private $id;
   ....
}

and

/** @OneToMany(targetEntity="TrackAlbum", mappedBy="album") */
protected $tracklist;

/** @OneToMany(targetEntity="AlbumTrack", mappedBy="track") */
protected $albumsFeaturingThisTrack;

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

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

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

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


当从专辑类中获取所有专辑曲目时,您将为另一条记录生成另一个查询。这是因为代理方法。这是我的另一个代码示例(请参阅主题中的最后一篇文章):http://groups.google.com/group/doctrine-user/browse_thread/thread/d1d87c96052e76f7/436b896e83c10868#436b896e83c10868

有没有其他方法可以解决这个问题?单个连接不是更好的解决方案吗?


我想我会同意@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和Repository逻辑,但是你的服务会忽略你正在传递一个Album或AlbumTrackReference,或者一个Track或一个AlbumTrackReference的事实,因为你已经把所有东西都隐藏在接口后面了:)

希望这能有所帮助!


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

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

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

考虑一下主键

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


我从一个关联类(带有额外的自定义字段)注释中定义的连接表和一个多对多注释中定义的连接表的冲突中得到。

具有直接多对多关系的两个实体中的映射定义似乎导致使用'joinTable'注释自动创建连接表。然而,连接表已经由其底层实体类中的注释定义,我希望它使用这个关联实体类自己的字段定义,以便使用其他自定义字段扩展连接表。

The explanation and solution is that identified by FMaz008 above. In my situation, it was thanks to this post in the forum 'Doctrine Annotation Question'. This post draws attention to the Doctrine documentation regarding ManyToMany Uni-directional relationships. Look at the note regarding the approach of using an 'association entity class' thus replacing the many-to-many annotation mapping directly between two main entity classes with a one-to-many annotation in the main entity classes and two 'many-to-one' annotations in the associative entity class. There is an example provided in this forum post Association models with extra fields:

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;

}

解决方案在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

我希望这对你们有帮助


单向。只需添加反向的by:(外列名),使其成为双向的。

# 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

我希望这能有所帮助。 见到你。


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