tjtjtjのメモ

自分のためのメモです

PlayFramework2.1.3(Java)+JPA 使ってみる #3

play2.1(Java)でJPAを使ってみたメモの続き。

UnitTestできるようになったので、UnitTestを書いてみる。DDLJPAにやらせるので、evolution はパスで。

フィスクチャ注入

play1の play.test.Fixtures みたいのは Ebeanにはある。junit 上で投入してないけど。サンプル中のフィクスチャ投入コードがこれ。

Global.java - samples/java/zentasks

public class Global extends GlobalSettings {
    public void onStart(Application app) {
        InitialData.insert(app);
    }

    static class InitialData {
        public static void insert(Application app) {
            if(Ebean.find(User.class).findRowCount() == 0) {

                Map<String,List<Object>> all = (Map<String,List<Object>>)Yaml.load("initial-data.yml");

                // Insert users first
                Ebean.save(all.get("users"));

                // Insert projects
                Ebean.save(all.get("projects"));
                for(Object project: all.get("projects")) {
                    // Insert the project/user relation
                    Ebean.saveManyToManyAssociations(project, "members");
                }

                // Insert tasks
                Ebean.save(all.get("tasks"));
            }
        }
    }
}

おっと Global.java は GlobalSettings を継承して onStart フックし初期データを注入してる。play1 の @OnApplicationStart はこうなるのか。GlobalSettings のオーバーライドは ここ(インターセプター)で説明されてる。
h2:mem を使っているうちは、データが揮発してしまうので、onStart で注入するのもありだな。

JPAでは、サンプルのようにEBeanのフィスクチャ注入ができないので、EntityManager.createNativeQuery でやってみる。

とりあえずテスト書く

public class ModelTest
{
    @Test
    public void findByIdTest()
    {
        running(fakeApplication(), new Runnable() {
            public void run() {
                JPA.withTransaction(new play.libs.F.Callback0() {
                    public void invoke() {

                        String sql1 = "delete from company";
                        JPA.em().createNativeQuery(sql1).executeUpdate();
                        String sql2 = "insert into company (id,name) values (1,'Apple Inc.')";
                        JPA.em().createNativeQuery(sql2).executeUpdate();

                        Company apple = Company.findById(1L);

                        assertThat(apple).isNotNull();
                        assertThat(apple.name).isEqualTo("Apple Inc.");

                    }
                });
            }
         });
    }
}

これはひどい。

書き直し

ここを参考に書き直し。
SQLクエリ生成を EntityManager.createNativeQuery から DB.getConnection().createStatement() にしてみた。
DB.getConnection().createStatement() だとパラメータを渡せない(?)から使えないけど、フィクスチャ投入くらいになら使えるね。

package models;

import static org.fest.assertions.Assertions.*;

import java.util.Arrays;
import java.util.List;

import org.junit.Test;

public class ModelTest extends utils.UnitTest
{
    @Override
    public List<String> getFixtureSource()
    {
        return Arrays.asList(
                "delete from company",
                "insert into company (id,name) values (1,'Apple Inc.')"
                );
    }

    @Test
    public void findByIdTest()
    {
        Company apple = Company.findById(1L);

        assertThat(apple).isNotNull();
        assertThat(apple.name).isEqualTo("Apple Inc.");
    }
}
package utils;

import java.sql.SQLException;
import java.util.Collections;
import java.util.List;

import org.junit.After;
import org.junit.Before;

import play.Logger;
import play.db.DB;
import play.db.jpa.JPA;
import play.db.jpa.JPAPlugin;
import play.test.FakeApplication;
import play.test.Helpers;
import scala.Option;

public abstract class UnitTest
{
    protected FakeApplication app;
    protected javax.persistence.EntityManager em;

    @Before
    public void setUp()
    {
        FakeApplication app = Helpers.fakeApplication();
        Helpers.start(app);

        Option<JPAPlugin> jpaPlugin = app.getWrappedApplication().plugin(JPAPlugin.class);
        em = jpaPlugin.get().em("default");
        JPA.bindForCurrentThread(em);
        em.getTransaction().begin();

        loadFixtures();
    }

    @After
    public void tearDown()
    {
        em.getTransaction().commit();
        em.close();
        JPA.bindForCurrentThread(null);
        Helpers.stop(app);
    }

    protected void loadFixtures()
    {
        for (String sql : getFixtureSource()) {
            execSql(sql);
        }
    }

    protected List<String> getFixtureSource()
    {
        return Collections.emptyList();
    }

    protected void execSql(String sql)
    {
        try {
            DB.getConnection().createStatement().execute(sql);
        } catch (SQLException e) {
            Logger.error("execsql failed:" + sql, e);
        }
    }
}

ましになった。fakeApplication とか JPA.withTransaction とか書かなきゃいけないのは面倒だな。
Fixture の置き場所どうしようか。confディレクトリ下なんだっけ?

PlayFramework2.1.3(Java) テスト0件問題を回避

play2.1.3(java)で UnitTestができない問題。

とのことですた。

MLからパッチをいただく

Play 2.1.3 released - groups.google.com play-framework より

testOptions in Test ~= { args =>
  for {
    arg <- args
    val ta: Tests.Argument = arg.asInstanceOf[Tests.Argument]
    val newArg = if(ta.framework == Some(TestFrameworks.JUnit)) ta.copy(args = List.empty[String]) else ta
  } yield newArg
}

パッチあて

どこにあてんの?と思ったら project/Build.scala でした。

パッチをあてた samples/java/computer-database-jpa の project/Build.scala

import sbt._
import Keys._
import play.Project._

object ApplicationBuild extends Build {

    val appName         = "computer-database-jpa"
    val appVersion      = "1.0"

    val appDependencies = Seq(
      javaCore,
      javaJdbc,
      javaJpa,
      "org.hibernate" % "hibernate-entitymanager" % "3.6.9.Final"
    )

    val main = play.Project(appName, appVersion, appDependencies).settings(
      ebeanEnabled := false,

      // PATCH : JUnit tests in Play do not execute
      testOptions in Test ~= { args =>
        for {
          arg <- args
          val ta: Tests.Argument = arg.asInstanceOf[Tests.Argument]
          val newArg = if(ta.framework == Some(TestFrameworks.JUnit)) ta.copy(args = List.empty[String]) else ta
        } yield newArg
      }
    )
}

テスト結果

[computer-database-jpa] $ test
[info] play - datasource [jdbc:h2:mem:play-test--899905098;] bound to JNDI as DefaultDS
[info] IntegrationTest
[info] + IntegrationTest.test
[info]
[info]
[info] Total for test IntegrationTest
[info] Finished in 0.018 seconds
[info] 1 tests, 0 failures, 0 errors
[info] play - datasource [jdbc:h2:mem:play] bound to JNDI as DefaultDS
[info] play - datasource [jdbc:h2:mem:play] bound to JNDI as DefaultDS
[info] play - datasource [jdbc:h2:mem:play] bound to JNDI as DefaultDS
[info] play - datasource [jdbc:h2:mem:play] bound to JNDI as DefaultDS
[info] FunctionalTest
[info] + FunctionalTest.filterComputerByName
[info] + FunctionalTest.redirectHomePage
[info] + FunctionalTest.createANewComputer
[info] + FunctionalTest.listComputersOnTheFirstPage
[info]
[info]
[info] Total for test FunctionalTest
[info] Finished in 0.01 seconds
[info] 4 tests, 0 failures, 0 errors
[info] play - datasource [jdbc:h2:mem:play] bound to JNDI as DefaultDS
[info] play - datasource [jdbc:h2:mem:play-test--1376794684;] bound to JNDI as DefaultDS
[info] ModelTest
[info] + ModelTest.findById
[info] + ModelTest.pagination
[info]
[info]
[info] Total for test ModelTest
[info] Finished in 0.004 seconds
[info] 2 tests, 0 failures, 0 errors
[info] Passed: : Total 7, Failed 0, Errors 0, Passed 7, Skipped 0
[success] Total time: 12 s, completed 2013/08/15 21:17:30

無事テストできますた。

参考

Play 2.1.3 released - groups.google.com play-framework

SBT セッティングについて - playframework-ja.org
ここの「デフォルト値の Play プロジェクトセッティング」を読んでおくといいかもしれない。scalaを読めるようになっておくといいかもしれない。最近書いてないのでまた読めなくなってきた。

PlayFramework2.1.3(Java)+JPA 使ってみる #2

play2.1(Java)でJPAを使ってみたメモの続き。

コントローラーから検証#1

UnitTestをあきらめて、コントローラーからDBアクセスを検証してみる。

app/controllers/Application.java

public class Application extends Controller {
    public static Result tests()
    {
        Company company = Company.findById(1L);
        if (company == null) {
            return notFound();
        }
        return ok(company.name);
    }
}

conf/routes

GET     /tests                       controllers.Application.tests()

play1のような感覚で実装できた。コントローラーメソッドの戻り値が void から Result に、Result は Exceptionでなくなった。
play1 の throw result は面白かったけど throw this のような違和感があった。独自Resultクラスで throw this と書いたことがあります。やめたけど。

http://localhost:9000/tests にアクセスすると、

[RuntimeException: No EntityManager bound to this thread. Try to annotate your action method with @play.db.jpa.Transactional]

と表示された。親切なメッセージだなあ。

testsアクションを @play.db.jpa.Transactional でアノテートとする。これはドキュメントにも載っている通り。2.3のころにはアノテートしなくてもよくなるかな。

アノテートして http://localhost:9000/tests へ。

[PersistenceException: org.hibernate.exception.SQLGrammarException: could not load an entity: [models.Company#1]]

h2データベースに接続したみたいだ。SQLGrammarException は create table も insert もしていないので当然か。

コントローラーから検証#2

コントローラーでDDLとテストデータを投入してみる。JPA.em() で EntityManager 取得できるのは play1と同じ感覚。

    @play.db.jpa.Transactional
    public static Result tests() {
        String sql1 = "create table company ( "
                + "id bigint not null, "
                + "name varchar(255), "
                + "constraint pk_company primary key (id))";
        String sql2 = "insert into company (id,name) values (  1,'Apple Inc.')";
        JPA.em().createNativeQuery(sql1).executeUpdate();
        JPA.em().createNativeQuery(sql2).executeUpdate();

        Company company = Company.findById(1L);
        if (company == null) {
            return notFound();
        }
        return ok(company.name);
    }

コントローラーでテストデータ投入#2

    @play.db.jpa.Transactional
    public static Result tests()
    {
        Company c = new Company();
        c.id = 2L;
        c.name = "asdf";
        c.save();

        Company company = Company.findById(2L);
        if (company == null) {
            return notFound();
        }
        return ok(company.name);
    }

DDL自動生成

play1 にあった jpa.ddl=create-drop をやりたい。
conf/META-INF/persistence.xml に次を加えたらできた。

        <properties>
            <property name="hibernate.hbm2ddl.auto" value="create-drop"/>    <--- 追加
        </properties>

DDL自動生成ができると、おおよそのモデルができるまで evolution 作成を先延ばしにできるので捗る。

PlayFramework2.1.3(Java)+JPA 使ってみる #1

Play 2.x RoadmapによるとPlayFramework2.3(Java)では、EBeanでなくJPAが推奨されるようです。職場ではplay1を使っているが、play2でJPAを使えるなら移行しやすいかもしれない。2.3を待たずPlayFramework2.1.3(Java)+JPAでどれだけやれるか試そう。と思った。
以下は、play2.1(Java)でJPAを使ってみた時のメモです。

アプリケーション作成

Play のインストールのつぎにアプリケーション作成する。

$play2 new jpaprac1

コマンドも結構変わってるみたいね。playコマンドでplayコンソールに入り、実行するコマンドもあるようだ。

Play コンソールを使う

ディレクトリ構成はplay1と似てる。projectディレクトリとtargetディレクトリが増えたな。

Play アプリケーションの構造

JNDI を経由してデータソースを公開する

play2でJPAが使えるかどうかを検証することが目的なので、コントローラー、ビューは飛ばしてモデル周りを見ていく。
ドキュメント通りにやる。

conf/application.conf

db.default.driver=org.h2.Driver
db.default.url="jdbc:h2:mem:play"
db.default.jndiName=DefaultDS
jpa.default=defaultPersistenceUnit

[2013.8.30] 追記 ドキュメントに書いてないけど `jpa.default=defaultPersistenceUnit` も追加する。

play1から見慣れた application.conf な感じ。play2はマルチDBがサポートされたので default は接続先名なんだろう。

設定ファイルのシンタックスと機能

フレームワークIDははくなった、といつかどこかで聞いた気がする。

JPA の実装をプロジェクトに追加する

play2.1はJPA関連に依存していないので、使用ライブラリに追加する必要があると。

ドキュメントに書いて無くてちょっと躓いたが、これは project/Build.scala を編集する。

samples/java/computer-database-jpa の project/Build.scala

import sbt._
import Keys._
import play.Project._

object ApplicationBuild extends Build {

    val appName         = "computer-database-jpa"
    val appVersion      = "1.0"

    val appDependencies = Seq(
      javaCore,
      javaJdbc,
      javaJpa,
      "org.hibernate" % "hibernate-entitymanager" % "3.6.9.Final"
    )

    val main = play.Project(appName, appVersion, appDependencies).settings(
      ebeanEnabled := false
    )
}

依存ライブラリは conf/dependencies.yml でなくここに書くことになったのか。conf/application.conf に書いてた name, version もここに書くと。

Build.scala を編集したらplay reload (または playコンソールでreloadコマンド)する。と、次回testしたとき依存ライブラリが解決されるようだ。
runのときは「auto-reloading」してくれる。とりあえず reload コマンドは覚えておこう。ライブラリを追加したら play eclipse もしておくとよさそうだ。

ビルドシステム
ライブラリ依存性の管理

永続性ユニットを作成する

ドキュメント通りにやる。
samples/java/computer-database-jpa の persistence.xml をコピペでもいい。

<persistence xmlns="http://java.sun.com/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
             version="2.0">

    <persistence-unit name="defaultPersistenceUnit" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.ejb.HibernatePersistence</provider>
        <non-jta-data-source>DefaultDS</non-jta-data-source>
        <properties>
            <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/>
        </properties>
    </persistence-unit>

</persistence>

play.db.jpa.JPA ヘルパーを使う

ドキュメント通りにやる。あるいは samples/java/computer-database-jpa の Company.java を参考にする。

@Entity
public class Company {

    @Id
    public Long id;

    @Constraints.Required
    public String name;

    public static Company findById(Long id) {
        return JPA.em().find(Company.class, id);
    }

    public void save() {
        JPA.em().persist(this);
    }
}

play2.1.3 にはJPA用のModelクラスがないので、そのうち自作する必要がありそうだ。

ユニットテスト(できなかった)

アプリケーションのテスト

DDLも作っていないが、ここまでをテストしてみる。DBに接続できない、テーブルがないくらいは出るだろうか。
samples/java/computer-database-jpa の ModelTest.java を参考にした。

public class ModelTest
{
    @Test
    public void findById() {
        running(fakeApplication(), new Runnable() {
           public void run() {
               JPA.withTransaction(new play.libs.F.Callback0() {
                   public void invoke() {
                       Company asdf = Company.findById(1L);
                       assertThat(asdf.name).isEqualTo("asdf");
                   }
               });
           }
        });
    }
}

これが噂の fakeApplication か。これはうざい。
play2のテスト実行はplayコンソールで test。しかし、テストされずスルーされてしまった。

[info] ModelTest
[info]
[info]
[info] Total for test ModelTest
[info] Finished in 0.001 seconds
[info] 0 tests, 0 failures, 0 errors
[info] Passed: : Total 0, Failed 0, Errors 0, Passed 0, Skipped 0

テストクラスは拾われたが、メソッドがスルーされてる。@Testアノテートしているのになぜだろう。
Eclipseからテストしたらテストできたようだ。これはあとで調査しよう。

ユニットテストできない問題

この issue のようです。

回避方法はここで。

Yiiでも中間テーブルにアクセスしたい

またこのネタです。

YiiFrameworkのCManyManyRelationは中間テーブルへのアクセスをサポートしていません。ならばオレオレリレーションによってそれを可能にしてみます。前エントリのまとめと反するのですが、まあ、あそびばってことで。
実現のためにCJoinElementを改造します。まだまだ中途半端な状態ですが載せておきます。気がむいたら更新するかもしれません。

2012.10.25 実験段階 とりあえずレイジーローディング

リレーション

まずオレオレリレーションモデルです。

protected/components/ManyManyPivotRelation.php

<?php
class ManyManyPivotRelation extends CManyManyRelation
{
    public $pivotModel = null;
    public $pivotWith = array();
}

モデル

続いてモデル

<?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(
            'movies_with_pivot' => array("ManyManyPivotRelation", 'Movie', 'viewer_watched_movie(viewer_id, movie_id)',
                'pivotModel' => 'ViewerWatchedMovie', // <- 中間テーブルモデル
                'pivotWith' => array('id','viewer_id', 'movie_id', 'liked',), // <-中間デーブルのカラム
            ),
        );
    }
}

viewer_watched_movie テーブルのPKはIDです。

<?php
class ViewerWatchedMovie extends CActiveRecord
{
    public static function model($className=__CLASS__)
    {
        return parent::model($className);
    }
    public function tableName()
    {
        return 'viewer_watched_movie';
    }
}
<?php
class Movie extends CActiveRecord
{
    public $pivot;   // <- ここに中間テーブルのモデルが入る
    public static function model($className=__CLASS__)
    {
        return parent::model($className);
    }
    public function tableName()
    {
        return 'movie';
    }
}

CJoinElement

次はCJoinElementに手を入れます。hack begin - hack end を挿入してください。
リレーションがManyManyPivotRelationならば、selectするカラムを追加し、select結果からpivotインスタンスを生成し、子モデルの $record->pivot にセットしています。

YiiFramework/framework/db/ar/CActiveFinder.php

<?php
class CJoinElement
{
	private function applyLazyCondition($query,$record)
	{
		$schema=$this->_builder->getSchema();
		$parent=$this->_parent;
		if($this->relation instanceof CManyManyRelation)
		{
        ....
			if($parentCondition!==array() && $childCondition!==array())
			{
				$join='INNER JOIN '.$joinTable->rawName.' '.$joinAlias.' ON ';
				$join.='('.implode(') AND (',$parentCondition).') AND ('.implode(') AND (',$childCondition).')';
				if(!empty($this->relation->on))
					$join.=' AND ('.$this->relation->on.')';
				$query->joins[]=$join;
				foreach($params as $name=>$value)
					$query->params[$name]=$value;
			}
			else
				throw new CDbException(Yii::t('yii','The relation "{relation}" in active record class "{class}" is specified with an incomplete foreign key. The foreign key must consist of columns referencing both joining tables.',
					array('{class}'=>get_class($parent->model), '{relation}'=>$this->relation->name)));

            // hack begin ----------
            if($this->relation instanceof ManyManyPivotRelation) {
                // add pivot columns 
                // TODO table.column -> t0_c0
	            foreach ($this->relation->pivotWith as $col) {
		            $query->selects[]=$joinAlias.'.'.$schema->quoteColumnName($col);
	            }
            }
            // hack end ----------
		}
		else
		{
        ....
		}
	}

	private function populateRecord($query,$row)
	{
        ....
        
		if(isset($this->records[$pk]))
			$record=$this->records[$pk];
		else
		{
			$attributes=array();
			$aliases=array_flip($this->_columnAliases);
			foreach($row as $alias=>$value)
			{
				if(isset($aliases[$alias]))
					$attributes[$aliases[$alias]]=$value;
			}
			$record=$this->model->populateRecord($attributes,false);
			foreach($this->children as $child)
			{
				if(!empty($child->relation->select)) {
					$record->addRelatedRecord($child->relation->name,null,$child->relation instanceof CHasManyRelation);
				}
			}
			$this->records[$pk]=$record;
			
            // hack begin ----------
            if($this->relation instanceof ManyManyPivotRelation) {
                // make pivot instance
	            //  TODO t0_c0 -> column_name
	            $p = new $this->relation->pivotModel;
 	            $record->pivot = $p->populateRecord($row,false);
            }
            // hack end ----------
			
		}

        ....
    }
}

ビュー

<?php
$viewers = Viewer::model()->findAll();
foreach($viewers as $viewer) {
    foreach ($viewer->movies_with_pivot as $movie) {
  	    echo $viewer->name . ($movie->pivot->liked ? ' liked ' : ' didn’t like ')
 	        . $movie->title;
    }
}

結果

hoge didn’t like foo
hoge liked bar
piyo liked foo
piyo didn’t like bar

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だな、というところに落ち着きました。まえに弱点と書きましたが、今回のような設計で使ってくれと言うことなんだと思います。