yabeチュートリアル その2 データモデル1巡目

※ 本記事では、Page not found — Playframeworkを和訳しています。

ここではブログエンジンのデータモデルを作りましょう。



Anormの紹介

モデル層はPlayアプリケーションにおいて中心的な位置づけとなります。アプリケーションが扱う、ドメイン固有の情報表現となります。ブログエンジンをつくるので、モデル層にはUserクラス、Postクラス、Commentクラスが含まれます。

ほとんどのモデルオブジェクトは、アプリケーションの起動をまたがって保持し続けたいので、我々はそれを永続データベースに保存する必要があります。一般的にはリレーショナルデータベースが選択されます。

Scalaモジュールは、Anormという全く新しいデータアクセス層を持ちます。AnormはデータベースリクエストのためにプレーンなSQLを使い、データセットを解析・変換するためのいくつかのAPIを提供します。

Anormは、ニーズに応じていくつかの使い方があります。ここでは、SQLクエリ結果を、データを表現するためのCaseクラスのセットに自動的にマッピングすることができる、発展的な方法で使います。



Userモデル

Userモデルを作ることで、ブログエンジンの開発を始めましょう。

最初に、データベースにUserテーブルを作成する必要があります。Playフレームワークの、SQLスキーマの変更を再編成するエボリューション機能を使います。以下のSQLスクリプトを記述した、db/evolutions/1.sql ファイルを作成してください。

# Users schema
 
# --- !Ups
 
CREATE TABLE User (
    id bigint(20) NOT NULL AUTO_INCREMENT,
    email varchar(255) NOT NULL,
    password varchar(255) NOT NULL,
    fullname varchar(255) NOT NULL,
    isAdmin boolean NOT NULL,
    PRIMARY KEY (id)
);
 
# --- !Downs
 
DROP TABLE User; 

アプリケーションのトップページをリロードして、Playフレームワークにこのスクリプトを適用させます。

ここではインメモリデータベースを使うので、またまだ空なので、Playフレームワークはワーニングを出力せず自動的にスクリプトを適用させます。以下のログ出力をコンソール上で確認してください

INFO ~ Automatically applying evolutions in in-memory database

さて、UserモデルのScalaクラスを用意しなければなりません。/yabe/app/models.scalaファイルを作成し、以下のようにコーディングしてください。

package models
 
import play.db.anorm._
import play.db.anorm.SqlParser._
 
// User
 
case class User(
    id: Pk[Long], 
    email: String, password: String, fullname: String, isAdmin: Boolean
)

このように、UserのCaseクラスはテーブル定義と一致します。Userテーブルを扱うSQLクエリ結果を解析するのに役立ちます。

さて、Userクラスのコンパニオンオブジェクトも用意しましょう。

object User extends Magic[User]

Magic型を使うには、play.db.anorm.defaults._ をインポートしておく必要があります。

このUserオブジェクトは、Magicオブジェクトを継承しています。Magicオブジェクト、パラメータを決める型を分析し、SQLクエリを解析・実行するための機能群を要求します。

アプリケーションのトップページをリロードすると結果を確認できます。実際には、間違っていない限り、変化を確認する必要はありません。Playフレームワークは自動的にUserクラスをコンパイルし、ロードしますが、この段階でアプリケーションに機能が追加されるわけではありません。




はじめてのテストを書く

新しく作ったUserクラスをテストするには、テストケースを書くのがいいです。それによって、都度都度アプリケーションが正しく、完璧に稼働することを確認することができます。

テストケースを稼働させるためには、テストモードでの起動が必要です。今稼働しているアプリケーションを停止させて、コマンドラインで以下のように入力してください。

~$ play test

play testコマンドは、play runコマンドとほとんど同じです。違うのは、テストケースをブラウザからダイレクトに稼働させることができるテストランナーモジュールをロードすることです。

テストモードでアプリケーションを稼働させるとき、Playフレームワークはテスト用フレームワークIDへ切り替えを行い、それに応じた application.conf ファイルをロードします。

ブラウザで、http://localhost:9000/@testsにアクセスし、テストランナーを確認してください。デフォルトのテストを全て選択して実行してみてください。オールグリーンになるはずです。ただ、デフォルトのテストは実体は何もテストしていません。

Scalaモジュールは、ScalaTestフレームワークをバンドルしています。

アプリケーションのモデル部分のテストをするには、振舞起動テストを使います。以下のようにデフォルトのTests.scalaファイルは既に存在しています。/yabe/test/Tests.scalaファイルを開いてみましょう。

import play._
import play.test._
 
import org.scalatest._
import org.scalatest.junit._
import org.scalatest.matchers._
 
class BasicTests extends UnitFlatSpec with ShouldMatchers {
    
    it should "run this dumb test" in {
        
        (1 + 1) should be (2)
        
    }
 
}

不要なテスト(it should "run this dumb test")を削除し、ユーザを登録して取得し直すテストを作ってみましょう。

import models._    
import play.db.anorm._
    
it should "create and retrieve a User" in {
    
    User.create(User(NotAssigned, "bob@gmail.com", "secret", "Bob", false))
   
    val bob = User.find(
        "email={email}").on("email" -> "bob@gmail.com"
    ).first()
   
    bob should not be (None)
    bob.get.fullname should be ("Bob")
   
}

見て分かるように、Userコンパニオンオブジェクトは、create(u:User)find(sql:String)というメソッドを提供しています。

UserテーブルのIDカラムは、自動生成列として宣言されているので、NotAssignedがIDの値として使われています。

Magicクラスのメソッドについては、Playフレームワークのマニュアルの、Anormの章を参照してください。

テストランナーの中で、BasicTestsを選択し、Startをクリックして、オールグリーンとなることを確認してください。

特定のユーザ名とパスワードが存在することをチェックするメソッドが必要なのでそれを書いて、テストしましょう。

models.scalaの中に、Userオブジェクトを応答するconnect()メソッドを追加しましょう。

object User extends Magic[User] {
    
    def connect(email: String, password: String) = {
        User.find("email = {email} and password = {password}")
            .on("email" -> email, "password" -> password)
            .first()
    }
    
}

テストケースはこんな感じ。

it should "connect a User" in {
    
    User.create(User(NotAssigned, "bob@gmail.com", "secret", "Bob", false))
    
    User.connect("bob@gmail.com", "secret") should not be (None)
    User.connect("bob@gmail.com", "badpassword") should be (None)
    User.connect("tom@gmail.com", "secret") should be (None)
    
}

修正する度にテストランナーで全てのテストを走らせば、アプリケーションを一切壊さずに済ませることができます。



Postモデル

Postモデルはブログ投稿を表示することができます。

Userモデルと同様に、Postテーブルをデータベース内に作らないといけません。これは二つ目のデータベースのevolutionになります。以下のスクリプトを記述した、db/evolutions/2.sqlファイルを作成します。

# --- !Ups
 
CREATE TABLE Post (
    id bigint(20) NOT NULL AUTO_INCREMENT,
    title varchar(255) NOT NULL,
    content text NOT NULL,
    postedAt date NOT NULL,
    author_id bigint(20) NOT NULL,
    FOREIGN KEY (author_id) REFERENCES User(id),
    PRIMARY KEY (id)
);
 
# --- !Downs
 
DROP TABLE Post;

この時、データベースは空ではありません。Playフレームワークは、このevolutionを適用する前に、適用していいかどうか問い合わせます。

f:id:teppei-studio:20110608191211p:image

そして、このテーブルをScalaクラスにマッピングします。

case class Post(
    id: Pk[Long], 
    title: String, content: String, postedAt: Date, author_id: Long
)
 
object Post extends Magic[Post]

このPostクラスを使うには、java.util.Dateをインポートしておいてください。

Postモデルが期待通り動くか確認するテストを作りましょう。しかしもっとテストを書く前に、テストの中でやらなければならないことがあります。今のテストでは、データベースの内容が削除されず、いちいち新しいテストが稼働するたびに、オブジェクトがどんどん作られて行ってしまいます。より複雑なテストがオブジェクトをカウントして正しい挙動を確認するようになると、この問題はすぐに顕在化してきます。

なので、テストの度にデータベースを削除しておく必要があります。BeforeAndAfterEachtraitをテストクラスにミックスインして、beforeEachメソッドをオーバーライドしてください。

… extends UnitFlatSpec with ShouldMatchers with BeforeAndAfterEach {
    
    import models._    
    import play.db.anorm._
    
    override def beforeEach() {
        Fixtures.deleteDatabase()
    }
    
 …

このように、Fixturesクラスはテスト中にデータベースを扱うヘルパーの役割を担います。テストをもう一度稼働させて、何も壊していないことを確認した後、次のテストを記述しましょう。

it should "create a Post" in {
    
    User.create(User(Id(1), "bob@gmail.com", "secret", "Bob", false))     
    Post.create(Post(NotAssigned, "My first post", "Hello!", new Date, 1))
    
    Post.count().single() should be (1)
    
    val posts = Post.find("author_id={id}").on("id" -> 1).as(Post*)
    
    posts.length should be (1)
    
    val firstPost = posts.headOption
    
    firstPost should not be (None)
    firstPost.get.author_id should be (1)
    firstPost.get.title should be ("My first post")
    firstPost.get.content should be ("Hello!")
    
}

さて、我々の使い方を考えると、各Post記事が作成者のホームページにくくり付いているようにしたくなりますよね。では、Postオブジェクトに、ひとつのクエリでUserオブジェクトとPostオブジェクトをくくり付かせるメソッドを追加しましょう。

select * from Post p 
join User u on p.author_id = u.id 
order by p.postedAt desc

これはauthor_idカラムで行う単純なJOINです。

もしSQL文をテストしたければ、http://localhost:9000/@db で、SQLコンソールににアクセスすることができます。

Postオブジェクトに、SQLクエリを実行するメソッドを追加しましょう。

def allWithAuthor:List[(Post,User)] = 
    SQL(
        """
            select * from Post p 
            join User u on p.author_id = u.id 
            order by p.postedAt desc
        """
    ).as( Post ~< User ^^ flatten * )

ここでは、JDBCの結果をList[(Post,User)]として解析して変換する構造を使っています。ここのパーサーは非常にシンプルです。

  • 行毎にPostデータをUserに括り付ける。
  • flatten は Post~User をよりシンプルな (Post,User) 構造にに変換。(テンプレートを使うよりもシンプルになります)
  • * を使うことで、行毎にこれを繰り返す

最後のパーサーはPost ~< User ^^ flatten *と記述します。

最後に、突合テストを追加しましょう。

it should "retrieve Posts with author" in {
    
    User.create(User(Id(1), "bob@gmail.com", "secret", "Bob", false)) 
    Post.create(Post(NotAssigned, "My 1st post", "Hello world", new Date, 1))
    
    val posts = Post.allWithAuthor
    
    posts.length should be (1)
    
    val (post,author) = posts.head
    
    post.title should be ("My 1st post")
    author.fullname should be ("Bob")
}

コメントを追加

ドラフト版モデルに最後に追加しなければいけないのは、Postに対してコメントを付ける機能です。

いつものように、evolutionスクリプトを作成し、データベーススキーマを更新してください。スクリプトファイルは、db/evolutions/3.sqlになります。

# --- !Ups
 
CREATE TABLE Comment (
    id bigint(20) NOT NULL AUTO_INCREMENT,
    author varchar(255) NOT NULL,
    content text NOT NULL,
    postedAt date NOT NULL,
    post_id bigint(20) NOT NULL,
    FOREIGN KEY (post_id) REFERENCES Post(id) ON DELETE CASCADE,
    PRIMARY KEY (id)
);
 
# --- !Downs
 
DROP TABLE Comment;

リロードして、evolutionを適用してください。そして、突合構造を作ります。

case class Comment(
    id: Pk[Long], 
    author: String, content: String, postedAt: Date, post_id: Long
) 
 
object Comment extends Magic[Comment]

また、Postオブジェクトに新しいメソッドを追加します。コメントリストと一緒にPostデータを取得するためのメソッドです。最初のメソッドは全件取得するメソッドです。

def allWithAuthorAndComments:List[(Post,User,List[Comment])] = 
    SQL(
        """
            select * from Post p 
            join User u on p.author_id = u.id 
            left join Comment c on c.post_id = p.id 
            order by p.postedAt desc
        """
    ).as( Post ~< User ~< Post.spanM( Comment ) ^^ flatten * )

ここでは、パーサーは少し複雑です。Post.spanM( Comment )はいくつかの行を拡張し、Post毎にコメントリストを抜き取ります。

もうひとつのメソッドは、PostのidからOption[(Post,User,List[Comment])]を応答するメソッドです。

def byIdWithAuthorAndComments(id: Long):Option[(Post,User,List[Comment])] = 
    SQL(
        """
            select * from Post p 
            join User u on p.author_id = u.id 
            left join Comment c on c.post_id = p.id 
            where p.id = {id}
        """
    ).on("id" -> id).as( Post ~< User ~< Post.spanM( Comment ) ^^ flatten ? )

さて、これらのメソッドをテストする時間です。

it should "support Comments" in {
    
    User.create(User(Id(1), "bob@gmail.com", "secret", "Bob", false))  
    Post.create(Post(Id(1), "My first post", "Hello world", new Date, 1))
    Comment.create(Comment(NotAssigned, "Jeff", "Nice post", new Date, 1))
    Comment.create(Comment(NotAssigned, "Tom", "I knew that !", new Date, 1))
    
    User.count().single() should be (1)
    Post.count().single() should be (1)
    Comment.count().single() should be (2)
    
    val Some( (post,author,comments) ) = Post.byIdWithAuthorAndComments(1)
    
    post.title should be ("My first post")
    author.fullname should be ("Bob")
    comments.length should be (2)
    comments(0).author should be ("Jeff")
    comments(1).author should be ("Tom")
    
}

うまくいきましたか?

最後に、コメント追加をシンプルにするヘルパーメソッドをCommentオブジェクトに追加します。

object Comment extends Magic[Comment] {
    
    def apply(post_id: Long, author: String, content: String) = {
        new Comment(NotAssigned, author, content, new Date(), post_id)
    }
    
}

Fixturesを使ってもっと複雑なテストを

もっと複雑なテストを書き出した時、データのセットがしばしば必要になります。FixturesはYAMLファイルにモデルを記述し、テスト前にいつでもロードします。

/yabe/test/data.ymlを編集し、Userを記述しましょう。

- !!models.User
    id:             !!Id[Long] 1
    email:          bob@gmail.com
    password:       secret
    fullname:       Bob
    isAdmin:        true
 
...

このファイルはちょっと大きいので、ここからダウンロードしてください。

Yamlファイルのロードはとても簡単です。

Yaml[List[Any]]("data.yml").foreach { 
    _ match {
        case u:User => User.create(u)
        case p:Post => Post.create(p)
        case c:Comment => Comment.create(c)
    }
}

Yamlヘルパーは、Yamlデータをクラスインスタンスの中に転送し、これらのインスタンスをデータベースに入れ込むMagicオブジェクトの代わりに使えます。

さて、データをロードし、いくつかのチェックを行うテストを作りましょう。

it should "load a complex graph from Yaml" in {
    
    Yaml[List[Any]]("data.yml").foreach { 
        _ match {
            case u:User => User.create(u)
            case p:Post => Post.create(p)
            case c:Comment => Comment.create(c)
        }
    }
    
    User.count().single() should be (2)
    Post.count().single() should be (3)
    Comment.count().single() should be (3)
    
    User.connect("bob@gmail.com", "secret") should not be (None)
    User.connect("jeff@gmail.com", "secret") should not be (None)
    User.connect("jeff@gmail.com", "badpassword") should be (None)
    User.connect("tom@gmail.com", "secret") should be (None)
    
    val allPostsWithAuthorAndComments = Post.allWithAuthorAndComments
    
    allPostsWithAuthorAndComments.length should be (3) 
    
    val (post,author,comments) = allPostsWithAuthorAndComments(2)
    post.title should be ("About the model layer")
    author.fullname should be ("Bob")
    comments.length should be (2)
    
    // We have a referential integrity error error
    User.delete("email={email}")
        .on("email"->"bob@gmail.com").executeUpdate().isLeft should be (true)
    
    Post.delete("author_id={id}")
        .on("id"->1).executeUpdate().isRight should be (true)
        
    User.delete("email={email}")
        .on("email"->"bob@gmail.com").executeUpdate().isRight should be (true)
    
    User.count().single() should be (1)
    Post.count().single() should be (1)
    Comment.count().single() should be (0)
    
}

これでブログエンジンの大部分は完了です。これでWebアプリケーションそのものを作成し、テストすることができるようになりました。