291

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

假设我们有一张像Metallica的Master of Puppets这样的专辑,里面有几首曲目。但请注意,一首曲目可能会出现在一张专辑中,就像Metallica 的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对象。我无法创建代理方法,如果两者兼而有之怎么办,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();
 }

这不是一个非常干净的方式。

4

13 回答 13

163

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

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

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

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

于 2011-11-02T14:10:41.587 回答
17

从 $album->getTrackList() 你将总是得到“AlbumTrackReference”实体,那么从 Track 和代理添加方法呢?

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 或其他东西获得,这也允许更清晰的分离。

于 2010-08-25T11:53:20.590 回答
13

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

对于寻找 3 个参与类之间的一对多/多对一关联以在关系中存储额外属性的干净编码示例的人,请查看此站点:

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

想想你的主键

还要考虑您的主键。您通常可以将复合键用于此类关系。Doctrine 本身就支持这一点。您可以将引用的实体设置为 id。 在此处查看有关复合键的文档

于 2013-04-05T16:12:17.633 回答
10

我想我会接受@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();
    }
}

这样,通过删除直接引用 aTrack或 an的逻辑,Album并将其替换为使用 a TrackInterfaceor AlbumInterface,您可以AlbumTrackReference在任何可能的情况下使用您的。您需要做的是稍微区分接口之间的方法。

这不会区分 DQL 或存储库逻辑,但您的服务将忽略您传递 anAlbum或 anAlbumTrackReference或 aTrack或 an的事实,AlbumTrackReference因为您已将所有内容隐藏在接口后面:)

希望这可以帮助!

于 2012-06-07T22:59:42.427 回答
7

首先,我基本同意 beberlei 的建议。但是,您可能正在将自己设计成一个陷阱。您的域似乎将标题视为曲目的自然键,这可能是您遇到的 99% 场景的情况。但是,如果木偶大师上的Battery是与The Metallica Collection上的版本不同的版本(不同的长度、现场、原声、混音、重新制作等)怎么办。

根据您希望如何处理(或忽略)这种情况,您可以选择 beberlei 建议的路线,也可以在 Album::getTracklist() 中使用您建议的额外逻辑。就个人而言,我认为额外的逻辑对于保持 API 干净是合理的,但两者都有其优点。

如果您确实希望适应我的用例,您可以让 Tracks 包含一个对其他 Tracks 的自引用 OneToMany,可能是 $similarTracks。在这种情况下,轨道Battery将有两个实体,一个用于The Metallica Collection,一个用于Master of the Puppets。然后每个相似的 Track 实体将包含对彼此的引用。此外,这将摆脱当前的 AlbumTrackReference 类并消除您当前的“问题”。我同意它只是将复杂性转移到不同的点,但它能够处理以前无法处理的用例。

于 2010-09-29T21:50:07.643 回答
6

您要求“最佳方式”,但没有最佳方式。有很多方法,你已经发现了其中的一些。在使用关联类时如何管理和/或封装关联管理完全取决于您和您的具体领域,恐怕没有人可以向您展示“最佳方式”。

除此之外,可以通过从等式中删除 Doctrine 和关系数据库来大大简化问题。您问题的本质归结为一个关于如何在普通 OOP 中处理关联类的问题。

于 2010-08-25T18:59:41.073 回答
6

我从关联类(带有附加自定义字段)注释中定义的连接表和多对多注释中定义的连接表的冲突中得到了解决。

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

解释和解决方法是上面FMaz008标识的。在我的情况下,这要归功于论坛“ Doctrine Annotation Question ”中的这篇文章。这篇文章提请注意有关ManyToMany 单向关系的 Doctrine 文档。查看有关使用“关联实体类”的方法的注释,从而将两个主要实体类之间的多对多注释映射直接替换为主实体类中的一对多注释和两个“多对多” -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;

}
于 2013-10-03T13:38:00.530 回答
3

这个非常有用的例子。它缺乏文件学说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;
于 2010-10-29T00:26:45.620 回答
3

解决方案在 Doctrine 的文档中。在常见问题解答中,您可以看到:

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

所以你不再做 amanyToMany但你必须创建一个额外的实体并放入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

我希望它有帮助

于 2014-01-15T10:50:20.267 回答
3

单向。只需添加 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

我希望它有所帮助。再见。

于 2015-05-27T03:29:30.470 回答
2

您可以通过将 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!' : ''
    );
}

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

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

于 2010-09-07T18:28:31.637 回答
0

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

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

于 2011-11-03T21:15:37.173 回答
0

这是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();
    }
}
于 2017-05-31T13:29:57.897 回答