Doctrine2:在引用表中处理带有额外列的多对多关系的最佳方法

294
我想知道在Doctrine2中处理多对多关系的最佳、最干净和最简单的方法是什么。
假设我们有一张专辑像Master of Puppets by 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 对象。我不能创建代理方法,因为如果 AlbumTrack 都有 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();
 }

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


2
你如何处理AlbumTrackReference?例如$album->addTrack()或$album->removeTrack()? - Daniel
我不理解你关于上下文的评论。在我看来,数据不依赖于上下文。关于$album->getTracklist()[12]AlbumTrackRef对象,所以$album->getTracklist()[12]->getTitle()将始终返回曲目的标题(如果您使用代理方法)。而$track->getAlbums()[1]Album对象,所以$track->getAlbums()[1]->getTitle()将始终返回专辑的标题。 - Vinícius Fagundes
另一个想法是在 AlbumTrackReference 上使用两个代理方法,getTrackTitle()getAlbumTitle - Vinícius Fagundes
13个回答

166

有人知道如何使用Doctrine命令行工具生成新实体作为yml模式文件吗?这条命令:app/console doctrine:mapping:import AppBundle yml仍会为原始的两个表生成多对多关系,并忽略第三个表而不将其视为实体。:/ - Stphane
@Crozin 提供的 foreach ($album->getTracklist() as $track) { echo $track->getTrack()->getTitle(); }将关系视为实体 有什么不同?我认为他想问的是如何跳过关联实体并使用 foreach ($album->getTracklist() as $track) { echo $track->getTitle(); } 来检索曲目的标题。 - panda
8
“一旦一个关系有了数据,它就不再是一个关系了。” 这真的很启发人。我以前从来没有从实体的角度思考过关系! - Onion
1
如果关系已经被创建并用作多对多,那该怎么办呢?我们意识到需要在我们的多对多关系中添加额外的字段,因此我们创建了一个不同的实体。问题是,由于现有数据和具有相同名称的现有表格,它似乎不想成为朋友。有人尝试过这个吗? - tylerism
对于那些想知道的人:使用(已经存在的)多对多联结表作为其表创建一个实体是可行的,但是持有多对多关系的实体必须改为单向关系连接到新实体。同时,对外部的接口(获取/设置前多对多关系的方法)也很可能需要进行调整。 - Jakumi

17

从 $album->getTrackList() 获取的始终是 "AlbumTrackReference" 实体,那么是否可以添加来自 Track 和 Proxy 的方法?

class AlbumTrackReference
{
    public function getTitle()
    {
        return $this->getTrack()->getTitle();
    }

    public function getDuration()
    {
        return $this->getTrack()->getDuration();
    }
}
这样做可以使您的循环大大简化,以及所有与循环专辑曲目相关的代码,因为所有方法都只是在AlbumTrackReference内部进行代理。
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或其他方式提供的曲目,因此这样做可以更清晰地进行分离。


1
代理方法并不能完全解决问题(请查看我的编辑)。顺便说一句,你应该将AlbumT(...)重命名。- 很好的建议。 - Crozin
3
为什么在AlbumTrackReference对象上没有getAlbumTitle()和getTrackTitle()两种方法?这两种方法都会代理到它们各自的子对象。 - beberlei
目标是创建最自然的对象API。$album->getTracklist()[1]->getTrackTitle()$album->getTracklist()[1]->getTrack()->getTitle()一样好/不好。然而,似乎我必须拥有两个不同的类:一个用于专辑-曲目引用,另一个用于曲目-专辑引用 - 这实施起来太困难了。所以这可能是迄今为止最好的解决方案... - Crozin

13

一个好的例子胜过千言万语

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

考虑你的主键

还要考虑你的主键。你可以经常使用组合键来处理此类关系。Doctrine本身支持这种方式。你可以将引用实体转换成ID。 在这里查看有关组合键的文档


10

我认为我会采用@beberlei建议的使用代理方法的方式。为了让这个过程更简单,你可以定义两个接口:

interface AlbumInterface {
    public function getAlbumTitle();
    public function getTracklist();
}

interface TrackInterface {
    public function getTrackTitle();
    public function getTrackDuration();
}

然后,您的AlbumTrack都可以实现它们,而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();
    }
}

通过移除直接引用 TrackAlbum 的逻辑,并将其替换为使用 TrackInterfaceAlbumInterface 的方式,你可以在任何可能的情况下使用你的 AlbumTrackReference。你需要做的就是稍微区分接口之间的方法。

这不会区分 DQL 和 Repository 逻辑,但你的服务只需要忽略传递的是 AlbumAlbumTrackReferenceTrack 还是 AlbumTrackReference,因为你已经将所有东西都隐藏在一个接口后面:)

希望能对你有所帮助!


7
首先,我基本上同意beberlei的建议。但是你可能会陷入一个设计陷阱。你的域似乎认为标题是曲目的自然键,这在你遇到的99%的情况下可能是正确的。但是,如果Master of the Puppets中的Battery与The Metallica Collection中的版本不同(长度不同、现场演出、原声、混音、重新制作等),那该怎么办呢?
根据您想要处理(或忽略)该情况的方式,您可以选择beberlei提出的路线,或者只需按照Album::getTracklist()中的您提出的额外逻辑即可。个人认为,使用额外的逻辑来保持API清洁是合理的,但两者都有其优点。
如果您希望满足我的用例,您可以让Tracks包含一个自引用的OneToMany到其他Tracks,可能是$similarTracks。在这种情况下,有两个曲目实体,一个是The Metallica Collection的,另一个是Master of the Puppets的。然后,每个相似的Track实体都包含对彼此的引用。此外,这将消除当前的AlbumTrackReference类并消除您当前的“问题”。我确实同意它只是将复杂性移动到了不同的点,但它能够处理以前无法处理的用例。

6
我遇到了一个问题,涉及到一个关联类中定义的连接表(具有额外的自定义字段)注释和一个多对多注释中定义的连接表之间的冲突。
两个实体之间直接的多对多关系的映射定义似乎会导致使用“joinTable”注释自动创建连接表。然而,连接表已经由其基础实体类中的注释定义,并且我希望它使用此关联实体类自己的字段定义,以便扩展连接表并添加额外的自定义字段。
FMaz008已经给出了解释和解决方案。在我的情况下,要感谢论坛中的这篇帖子“Doctrine Annotation Question”。该帖子提醒我们注意Doctrine文档中关于ManyToMany Uni-directional relationships的内容。请查看有关使用“关联实体类”方法的注释,从而用一个主实体类中的一对多注释替换直接在两个主实体类之间进行的多对多注释映射,并在关联实体类中使用两个“多对一”注释。此论坛帖子中提供了一个示例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;

}

6
您要求“最佳方式”,但并不存在最佳方式。有很多种方法,您已经发现了其中一些。在使用关联类时,如何管理和/或封装关联管理完全取决于您的具体领域,恐怕没有人能向您展示“最佳方式”。
除此之外,通过从方程式中删除Doctrine和关系数据库,可以大大简化问题。您的问题的本质归结为如何在普通OOP中处理关联类的问题。

3

单向的。只需添加inversedBy:(外键列名)即可使其双向。

# 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

希望对您有所帮助。 再见。


3

这是一个非常有用的例子。在Doctrine 2的文档中缺少相关内容。

非常感谢。

对于代理函数,可以执行以下操作:

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;

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

所以你不再使用manyToMany,而是要创建一个额外的实体并将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

希望它有所帮助


这正是我一直在寻找的,谢谢!不幸的是,第三个用例没有yml示例!:( 有人能分享一个使用yml格式的第三个用例的示例吗?我会非常感激 :# - Stphane
我已经在答案中添加了你的情况 ;) - Mirza Selimovic
这是不正确的。实体不必具有id(id)AUTO。那是错误的,我正在尝试创建正确的示例。 - Gatunox
我会发布一个新答案以确保格式正确。 - Gatunox

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接