3項演算子が読みにくいとか難しいとか
3行で書けばいいじゃない。
String s = (condition) ? "hoge" : "piyo";
PlayFramework2.1.3(Java)+JPA 使ってみる #3
UnitTestできるようになったので、UnitTestを書いてみる。DDLはJPAにやらせるので、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ができない問題。
Play 2.1.3にするとJava版のテストが0件になる事案が発生し、MLでもあった模様 『Play 2.1.3 released https://t.co/6Ie7qdyB5Z
— Kazuhiro Hara(Legen) (@kara_d) 2013, 8月 7
Play 2.1.3 Javaでテスト0件な問題はMLにあった「testOptions in Test ~= { args => ...」なコードを入れることで無事動作しますた。
— Kazuhiro Hara(Legen) (@kara_d) 2013, 8月 7
とのことですた。
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
コントローラーから検証#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); }
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コンソールに入り、実行するコマンドもあるようだ。
ディレクトリ構成はplay1と似てる。projectディレクトリとtargetディレクトリが増えたな。
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からテストしたらテストできたようだ。これはあとで調査しよう。
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だな、というところに落ち着きました。まえに弱点と書きましたが、今回のような設計で使ってくれと言うことなんだと思います。