Yiiで中間テーブルに属性を持たせたいときはHasMany, BelongsToを使う

YiiFrameworkのCManyManyRelationは中間テーブルへのアクセス手段を用意してくれません。なので、中間テーブルに属性を持たせたいときはHasManyとBelongsToを使うのが、今のところエレガントな解決方法のようです。

http://www.yiiframework.com/wiki/285/accessing-data-in-a-join-table-with-the-related-models/#c6007
http://www.yiiframework.com/wiki/285/accessing-data-in-a-join-table-with-the-related-models/#c7483

この問題のサンプルコードにはtypoがたくさんあるので、検証コードを載せておきます。

テーブル

CREATE TABLE IF NOT EXISTS `viewer` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(45) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8;

CREATE TABLE IF NOT EXISTS `movie` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(45) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8;

CREATE TABLE IF NOT EXISTS `viewer_watched_movie` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `viewer_id` int(11) NOT NULL,
  `movie_id` int(11) NOT NULL,
  `liked` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `fk_viewer_watched` (`movie_id`),
  KEY `fk_movie_watched_by` (`viewer_id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8;

データ

INSERT INTO `viewer` (`id`, `name`) VALUES
(1, 'v1'),
(2, 'v2');
INSERT INTO `movie` (`id`, `title`) VALUES
(1, 'm1'),
(2, 'm2');
INSERT INTO `viewer_watched_movie` (`id`, `viewer_id`, `movie_id`, `liked`) VALUES
(1, 1, 1, 1),
(2, 1, 2, 0),
(3, 2, 1, 1);

モデル

<?php
class Viewer extends CActiveRecord
{
    public static function model($className=__CLASS__)
    {
        return parent::model($className);
    }
    public function tableName()
    {
        return 'viewer';
    }
    public function relations()
    {
        return array(
            'watched_movies' => array(self::HAS_MANY, 'ViewerWatchedMovie', 'viewer_id'),
        );
    }	
}
<?php
class Movie extends CActiveRecord
{
    public static function model($className=__CLASS__)
    {
        return parent::model($className);
    }
    public function tableName()
    {
        return 'movie';
    }
}
<?php
class ViewerWatchedMovie extends CActiveRecord
{
    public static function model($className=__CLASS__)
    {
        return parent::model($className);
    }
    public function tableName()
    {
        return 'viewer_watched_movie';
    }
    public function relations()
    {
        return array(
            'movie' => array(self::BELONGS_TO, 'Movie', 'movie_id'),
        );
    }
}

レイジーローディング

<?php
$viewer = Viewer::model()->findByPk(1);
foreach ($viewer->watched_movies as $watched) {
    echo $viewer->name . ($watched->liked ? ' liked ' : ' didn’t like ')
    . $watched->movie->title;
}

イーガーローディング

<?php
$viewers = Viewer::model()->with(array(
    'watched_movies'=>array(
        'condition'=>'watched_movies.liked=:liked',
        'params'=>array(':liked'=>true),
    ),
))->findAll();
foreach ($viewers as $viewer) {
    foreach ($viewer->watched_movies as $watched) {
        echo $viewer->name . ($watched->liked ? ' liked ' : ' didn’t like ')
        . $watched->movie->title;
    }
}

まとめのような

CActiveRecordの MANY_MANY, HAS_MANY...は単なるクラス名なので、次のようにオレオレリレーションのクラス名「OreOreRelation」でもいいんです。

<?php
class Viewer extends CActiveRecord {
    ....
    public function relations()	{
        return array(  
            'movies' => array('OreOreRelation', 'Movie', 'viewer_watched_movie(viewer_id, movie_id)', 
                'pivot_model'=>'ViewerWatchedMovie',
            ),
        );
    }	
}

で、オレオレリレーションでSQL書き換えればいいかと思ったらさにあらず、SELECT..JOIN...生成はリレーションクラスでなく、CActiveFinderというかCJoinElementがやっています。
中間テーブルを取得するリレーションを作ってみたのですが、CJoinElementの 「if (relation instanseof CManyManyRelation) 」している辺りを探りながらパッチ当てることで、レイジーローディングだけどうにかできました。書くまでもなく茨の道です。
そんなわけで、いまのところエレガントな解決方法はHasMany, BelongsToだな、というところに落ち着きました。まえに弱点と書きましたが、今回のような設計で使ってくれと言うことなんだと思います。