1. ホーム
  2. php

[解決済み】Doctrine2: 参照テーブルで余分なカラムを持つ多対多を処理する最良の方法

2022-04-01 22:20:08

質問

Doctrine2 で多対多のリレーションを扱うのに、もっとも簡単でクリーンな方法は何でしょうか?

例えば、次のようなアルバムがあるとします。 マスター・オブ・パペッツ by メタリカ を複数のトラックで表示します。しかし、1つの曲が複数のアルバムに収録されている場合がありますので、ご注意ください。 バッテリー メタリカ は、3枚のアルバムがこの曲を収録しています。

そこで、私が必要としているのは、アルバムとトラックの間の多対多の関係で、いくつかの追加列(指定されたアルバム内のトラックの位置など)を持つ3番目のテーブルを使用します。実際には、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

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

そして、それはあまりきれいな方法とは言えません。

解決方法は?

Doctrine ユーザーメーリングリストに同様の質問を投稿したところ、とてもシンプルな回答が返ってきました。

多対多のリレーションをエンティティとして考え、3つのオブジェクトを持ち、それらの間を一対多と多対一のリレーションでリンクしていることに気づきます。

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

リレーションがデータを持ったら、それはもうリレーションではない!?