Doctrine2:处理引用表中额外列的多对多的最佳方法


282

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

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


2
您如何处理AlbumTrackReference?例如$ album-> addTrack()或$ album-> removeTrack()?
丹尼尔(Daniel)

我不明白您对上下文的评论。在我看来,数据不取决于上下文。About $album->getTracklist()[12]AlbumTrackRef对象,因此$album->getTracklist()[12]->getTitle()将始终返回轨道的标题(如果使用代理方法)。虽然$track->getAlbums()[1]Album对象,所以$track->getAlbums()[1]->getTitle()将始终返回相册的标题。
维尼修斯FAGUNDES

另一个想法是使用AlbumTrackReference两种代理方法,getTrackTitle()getAlbumTitle
维尼修斯FAGUNDES

Answers:


158

我已经在“ Doctrine用户”邮件列表中打开了一个类似的问题,并且得到了一个非常简单的答案。

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

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

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


有人知道如何获得该学说的命令行工具来将这个新实体生成为yml模式文件吗?该命令:app/console doctrine:mapping:import AppBundle yml仍然为原始的两个表生成manyToMany关系,而只是忽略第三个表而不是将其视为一个实体:/
Stphane 2015年

foreach ($album->getTracklist() as $track) { echo $track->getTrack()->getTitle(); }@Crozin和提供的有什么区别consider the relationship as an entity?我想他想问的是如何跳过关系实体并使用foreach ($album->getTracklist() as $track) { echo $track->getTitle(); }
panda

6
“关系一旦有了数据,就不再是关系了”,这确实令人启发。我只是无法从实体的角度考虑关系!
洋葱

如果该关系已经被创建并使用了很多对呢?我们意识到我们需要许多额外的字段,因此我们创建了一个不同的实体。问题在于,对于现有数据和名称相同的现有表,它似乎不想成为朋友。有人尝试过吗?
暴政'17

对于那些想知道的人:创建一个实体(在表工作时,该实体具有可连接的(已经存在的)多对多),但是,拥有多对多关系的实体必须适应于改为与新实体一对多。与外部的接口(以前的多对多的获取者/设置者)最有可能需要进行调整。
Jakumi

17

从$ 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”)。显然,它不仅是参考,还包含其他逻辑。由于可能还存在一些未连接到专辑的音轨,只能通过promo-cd或其他方式使用,因此也可以使音轨更清晰。


1
代理方法不能100%解决问题(请检查我的编辑)。Btw You should rename the AlbumT(...)-好点
Crozin 2010年

3
你为什么没有两种方法?AlbumTrackReference对象上的getAlbumTitle()和getTrackTitle()?两者都代理到各自的子对象。
beberlei 2010年

目标是最自然的对象API。$album->getTracklist()[1]->getTrackTitle()与一样好/坏$album->getTracklist()[1]->getTrack()->getTitle()。但是,似乎必须有两个不同的类:一个用于专辑->音轨引用,另一个用于音轨->专辑引用-这太难实现了。所以可能这是迄今为止最好的解决方案……
Crozin 2010年


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

这样,通过删除直接引用Track或的逻辑Album,而只是替换它以使其使用TrackInterfaceAlbumInterface,就可以使用AlbumTrackReference在任何可能的情况下使用。您需要的是稍微区分接口之间的方法。

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

希望这可以帮助!


7

首先,我对贝贝雷伊的建议大体同意。但是,您可能正在将自己设计为陷阱。您的域似乎正在考虑将标题作为曲目的自然键,对于您遇到的99%的情况来说,情况很可能如此。但是,如果有什么电池的木偶大师是不同的版本(不同的长度,生活,声音,混音,翻唱等)比对版本的Metallica的收藏

根据您要处理(或忽略)该情况的方式,您可以采用beberlei的建议路线,也可以仅使用Album :: getTracklist()中建议的额外逻辑。就我个人而言,我认为可以使用额外的逻辑来保持API的整洁,但是两者都有其优点。

如果您确实希望容纳我的用例,则可以让Track包含一个将OneToMany自我引用到其他Track(可能是$ similarTracks)的自引用。在这种情况下,将是轨道两个实体电池,一个用于在Metallica的集合和一个用于在木偶大师。然后,每个类似的Track实体将包含彼此的引用。另外,这将摆脱当前的AlbumTrackReference类,并消除当前的“问题”。我确实同意,这只是将复杂性转移到了另一点,但是它能够处理以前无法实现的用例。


6

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

除此之外,通过从等式中删除主义和关系数据库,可以大大简化该问题。您问题的实质可以归结为有关如何在普通OOP中处理关联类的问题。


6

我从与关联类(具有其他自定义字段)注释中定义的联接表和多对多注释中定义的联接表的冲突中摆脱出来。

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

说明和解决方案由上面的FMaz008标识。就我而言,这要归功于论坛“ 学说注释问题 ” 中的这篇文章。这篇文章引起人们对有关多对多单向关系的学说文档的关注。请参阅有关使用“关联实体类”的方法的注释,该方法因此将两个主要实体类之间的多对多注释映射直接替换为主要实体类中的一对多注释和两个“许多对多”注释关联实体类中的-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;

}

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;

3

您指的是元数据,即有关数据的数据。对于我目前正在从事的项目,我也遇到了同样的问题,不得不花一些时间来弄清楚。在这里发布的信息太多,但是下面的两个链接可能对您有用。它们确实引用了Symfony框架,但是基于Doctrine ORM。

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也不错!


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

因此,您不再需要执行“ a”操作,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 2015年

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

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


2

您也许可以实现自己想要的 将TableTrackReference更改为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!' : ''
    );
}

您将需要彻底检查这一点,以确保您不会遭受性能方面的困扰。

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


0

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

还有其他解决方法吗?单一联接不是更好的解决方案吗?


1
尽管从理论上讲这可以回答问题,但最好在此处包括答案的基本部分,并提供链接以供参考。
Spontifixus 2012年

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();
    }
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.