circumflexでyabeを作成 - Day1 (A first iteration of the data model)

Day1は、Circumflex ORMを使用し,
Play Frameworkのチュートリアルの「A first iteration of the data model」を元に作成していきます。
日本語:http://playscalaja.appspot.com/documentation/0.9.1/guide2
英語:http://www.playframework.org/modules/scala-0.9.1/guide2

出来る限り、似せて作成しています。

Userモデル

circumflexのORMは、SQLに近い形でモデルを書くことができます。

import ru.circumflex.orm._

class User extends Record[Long, User] with IdentityGenerator[Long, User] {
  val id = "id".BIGINT.NOT_NULL.AUTO_INCREMENT
  val email = "email".VARCHAR(255).NOT_NULL
  val password = "password".VARCHAR(255).NOT_NULL
  val fullname = "fullname".VARCHAR(255).NOT_NULL
  val isAdmin = "isAdmin".BOOLEAN.NOT_NULL

  def PRIMARY_KEY = id
  def relation = User
}

Recordクラスを継承して、テーブル定義を記述します。

また、コンパニオンオブジェクトを用意しておきます。

object User extends User with Table[Long, User] {
}

コンパニオンオブジェクトでは、主にSQLクエリや外部キーの設定を記述することになります。

モデルのテスト

circumflex-ormでは、テストにspecsを使用しており、play frameworkではscalatestを使用しています。
(play frameworkはsclaatestをバンドルしています。)

今回は、scalatestでテストしていきましょう。*1

import ru.circumflex.orm._

import org.scalatest._
import org.scalatest.junit._
import org.scalatest.matchers._

class BasicTests extends Spec with ShouldMatchers with BeforeAndAfterEach {

  it ("should create and retriece a User") {
    val user = new User
    user.email := "test@test.com"
    user.password := "secret"
    user.fullname := "Tarou"
    user.isAdmin := false
    user.INSERT()

    val tarou = (User AS "u").map{ u => SELECT(u.*).FROM(u).WHERE(u.email EQ "test@test.com").unique }

    tarou should not be (None)
    tarou.get.fullname() should be ("Tarou")
  }

}

circumflex-ormには、他のORMに存在するようなfindメソッドのような機能などは基本的に存在しません。*2
代わりに、SQLを書く感覚で記述することができます。

ここで注意してほしい点は、tarou.get.fullname()のです。
fullname()の括弧を省略することができません。
値を取り出すには必ず括弧をつけて呼び出す必要があります。

では、テストを実行してみましょう。

テストの実行前にテーブルを作成しておく必要がありますが、
テストのためにテーブルを用意するのは大変です。

それに関しても問題ありません。
circumflex-ormでは、テーブルを作成・削除するための機能があります。

では、テスト前には、テーブルのCREATEを行い、テスト後にはDROPすることをテストコードに記載しましょう。

class BasicTests extends Spec with ShouldMatchers with BeforeAndAfterEach {

  override def beforeEach() {
    new DDLUnit(User).CREATE()
  }

  override def afterEach() {
    new DDLUnit(User).DROP()
  }

  ...
}

DDLUnitクラスを使用して、コンストラクタに指定したモデルのテーブルを作成および削除が可能になります

また、Day0でも用意したcx.propertiesをテストDB用に準備する必要があります。
src/test/resources/cx.properties

orm.connection.driver=org.h2.Driver
orm.connection.url=jdbc:h2:mem:yabe
orm.connection.username=sa
orm.connection.password=

これで準備が整ったので、テストを開始しましょう。

sbt test

オールグリーンになったところで、Play frameworkのチュートリアルと同じように
ユーザとパスワードの存在チェックを行うconnectメソッドをUserに追加してみましょう。

object User extends User with Table[Long, User] {

  def connect(email: String, password: String): Option[User] = {
    (this AS "q").map{ q => SELECT(q.*).FROM(q).WHERE((q.email EQ email) AND (q.password EQ password)).unique }
  }
}

同じようにテストメソッドも追加しましょう。

it ("should connect a User") {
  val user = new User
  user.email := "test@test.com"
  user.password := "secret"
  user.fullname := "Tarou"
  user.isAdmin := false
  user.INSERT()

  User.connect("test@test.com", "secret") should not be (None)
  User.connect("test@test.com", "badpassword") should be (None)
  User.connect("bad@test.com", "secret") should be (None)
}

Postモデル

class Post extends Record[Long, Post] with IdentityGenerator[Long, Post] {
  val id = "id".BIGINT.NOT_NULL.AUTO_INCREMENT
  val title = "title".VARCHAR(255).NOT_NULL
  val content = "content".TEXT.NOT_NULL
  val postedAt = "postedAt".DATE.NOT_NULL
  val author_id = "author_id".BIGINT.NOT_NULL

  def PRIMARY_KEY = id
  def relation = Post
}

object Post extends Post with Table[Long, Post] {
  val fkey = CONSTRAINT("user_id_fkey").FOREIGN_KEY(User, this.author_id -> User.id)
}

外部キーは、コンパニオンオブジェクトに設定します。

テストコードも書きましょう。

it ("should create a Post") {
  val user = new User
  user.email := "test@test.com"
  user.password := "secret"
  user.fullname := "Tarou"
  user.isAdmin := false
  user.INSERT()

  val post = new Post
  post.title := "My first post"
  post.content := "Hello!"
  post.postedAt := new Date
  post.author_id := 1
  post.INSERT()

  val postCount = (Post AS "p").map{ p => SELECT(COUNT(p.id)).FROM(p).unique }.get
  postCount should be (1)

  val posts = (Post AS "p").map{ p => SELECT(p.*).FROM(p).WHERE(p.author_id EQ 1).list }
  posts.length should be (1)

  val firstPost = posts.headOption

  firstPost should not be (None)
  firstPost.get.author_id() should be (1)
  firstPost.get.content() should be ("Hello!")
}

PostとUserを結合してPostに紐付くUserをセットにするクエリを追加しましょう。

def allWithAuthor(): Seq[(Post, User)] = {
  val p = Post AS "p"
  val u = User AS "u"
  SELECT(p .* -> u.*).FROM(p JOIN u).WHERE(p.author_id EQ u.id).ORDER_BY(p.postedAt DESC).list.collect{ case (Some(po), Some(us)) => (po, us) }
}

2テーブルの結合はサポートされているため、戻り値の型がSeq[(Option[Post], Option[User])]になります。


テストコードも追加しましょう。

it ("should retrieve Posts with author") {
  val user = new User
  user.email := "test@test.com"
  user.password := "secret"
  user.fullname := "Tarou"
  user.isAdmin := false
  user.INSERT()

  val post = new Post
  post.title := "My 1st post"
  post.content := "Hello!"
  post.postedAt := new Date
  post.author_id := 1
  post.INSERT()

  val posts = Post.allWithAuthor
  posts.length should be (1)

  val (p, author) = posts.head

  p.title() should be ("My 1st post")
  author.fullname() should be ("Tarou")
}

Commentモデル

class Comment extends Record[Long, Comment] with IdentityGenerator[Long, Comment] {

  def this(post_id: Post, author: String, content: String) = {
   this()
   this.post_id := post_id
   this.author := author
   this.content := content
   this.postedAt := new java.util.Date()
  }
  val id = "id".BIGINT.NOT_NULL.AUTO_INCREMENT
  val author = "author".VARCHAR(255).NOT_NULL
  val content = "content".TEXT.NOT_NULL
  val postedAt = "postedAt".DATE.NOT_NULL
  val post_id = "post_id".BIGINT.NOT_NULL.REFERENCES(Post).ON_DELETE(CASCADE)

  def PRIMARY_KEY = id
  def relation = Comment
}

object Comment extends Comment with Table[Long, Comment] {
  val fkey = CONSTRAINT("post_id_fkey").FOREIGN_KEY(Post, this.post_id -> Post.id)
}

Postに紐付くコメントリストを取得するクエリを追加します。

def allWithAuthorAndComments(): Seq[(Post, User, Seq[Comment])] = {
  val p = Post AS "p"
  val u = User AS "u"
  val c = Comment AS "c"
  val ls = SELECT(p.* AS "post", u.* AS "user", c.* AS "comment").
           FROM((p JOIN u).ON(p.author_id EQ u.id).
           LEFT_JOIN(c).ON(c.post_id EQ p.id)).
           ORDER_BY(p.postedAt DESC).list

  (for {
      ((Some(p), Some(u)), xs) <- ls.groupBy{m => (m.get("post"), m.get("user"))}
  } yield (p.asInstanceOf[Post],
           u.asInstanceOf[User],
           xs.flatMap(_.get("comment")).map(_.asInstanceOf[Comment])
  )).toSeq
}

ここで注目してほしいところは、3テーブル以上を結合する場合、2テーブルの結合と違って、
戻り値の型がSeq[Map[String, Any]]になる点です。
そのため、ASで別名を付け、asInstanceOfでキャストしています。

もう一つ追加しておきましょう。

def byIdWithAuthorAndComments(id: Long): Option[(Post, User, Seq[Comment])] = {
  val p = Post AS "p"
  val u = User AS "u"
  val c = Comment AS "c"
  val ls = SELECT(p.* AS "post", u.* AS "user", c.* AS "comment").
           FROM((p JOIN u).
           ON(p.author_id EQ u.id).
           LEFT_JOIN(c).ON(c.post_id EQ p.id)).
           WHERE(p.id EQ id).list

  (for {
      ((Some(p), Some(u)), xs) <- ls.groupBy{m => (m.get("post"), m.get("user"))}
  } yield (p.asInstanceOf[Post],
           u.asInstanceOf[User],
           xs.flatMap(_.get("comment")).map(_.asInstanceOf[Comment])
  )).headOption
}

同じようにテストコードを追加する前にCommentモデルにコンストラクタを追加しておきます。

class Comment extends Record[Long, Comment] with IdentityGenerator[Long, Comment] {

  def this(post_id: Post, author: String, content: String) = {
   this()
   this.post_id := post_id
   this.author := author
   this.content := content
   this.postedAt := new java.util.Date()
  }
  ...

コンストラクタを追加することで、データの登録を1行で書けるようになります。


それでは、さっそくテストコードで見てみましょう。

it ("should support Comments") {
  val user = new User
  user.email := "test@test.com"
  user.password := "secret"
  user.fullname := "Tarou"
  user.isAdmin := false
  user.INSERT()

  val post = new Post
  post.title := "My 1st post"
  post.content := "Hello!"
  post.postedAt := new Date
  post.author_id := 1
  post.INSERT()

  new Comment(post, "Suzuki", "Nice post").INSERT()
  //val comment1 = new Comment
  //comment1.author := "Suzuki"
  //comment1.content := "Nice post"
  //comment1.postedAt := new Date
  //comment1.post_id := post
  //comment1.INSERT()

  new Comment(post, "Satou", "I knew that !").INSERT()
  //val comment2 = new Comment
  //comment2.author := "Satou"
  //comment2.content := "I knew that !"
  //comment2.postedAt := new Date
  //comment2.post_id := post
  //comment2.INSERT()

  val userCount = (User AS "u").map{ u => SELECT(COUNT(u.id)).FROM(u).unique }.get
  userCount should be (1)
  val postCount = (Post AS "p").map{ p => SELECT(COUNT(p.id)).FROM(p).unique }.get
  postCount should be (1)
  val commentCount = (Comment AS "c").map{ c => SELECT(COUNT(c.id)).FROM(c).unique }.get
  commentCount should be (2)

  val Some((p, author, comments)) = Post.byIdWithAuthorAndComments(1)

  p.title() should be ("My 1st post")
  author.fullname() should be ("Tarou")
  comments.length should be (2)
  comments(0).author() should be ("Suzuki")
  comments(1).author() should be ("Satou")

}

ここでも注目してほしい点があります。
Commentのコンストラクタのpost_idがPOST型になっていることです。
他のORMならばInt型になると思いますが、circumflexでは紐付く値は紐付くべき型の値を指定する必要があります。

複雑なテスト

Play Frameworkでは、YAMLを読み込んでテストを行っていますが、
Circumflex-ormでは、YAMLの代わりにXMLを使って同じ様なことが実現できます。

実際のデータはgithubにあるのでそちらを見てください。

では、テストデータをロードする部分を見てみましょう。

Deployment.readAll(XML.load(getClass.getResourceAsStream("/test.data.xml"))).foreach(_.process)

test.data.xmlはsrc/test/resources/にあります。

少し長いですが、テストコードを追加してみましょう。

it ("should load a complex graph from XML") {
  Deployment.readAll(XML.load(getClass.getResourceAsStream("/test.data.xml"))).foreach(_.process)

  val userCount = (User AS "u").map{ u => SELECT(COUNT(u.id)).FROM(u).unique }.get
  userCount should be (2)
  val postCount = (Post AS "p").map{ p => SELECT(COUNT(p.id)).FROM(p).unique }.get
  postCount should be (3)
  val commentCount = (Comment AS "c").map{ c => SELECT(COUNT(c.id)).FROM(c).unique }.get
  commentCount should be (3)

  User.connect("tarou@test.com", "secret") should not be (None)
  User.connect("suzuki@test.com", "secret") should not be (None)
  User.connect("suzuki@test.com", "badpassword") should be (None)
  User.connect("tanaka@test.com", "secret") should be (None)

  val allPostsWithAuthorAndComments = Post.allWithAuthorAndComments

  allPostsWithAuthorAndComments.length should be (3)

  val (post, author, comments) = allPostsWithAuthorAndComments(2)
  post.title() should be ("Scala Programming Language")
  author.fullname() should be ("Tarou")
  comments.length should be (2)

  // We have a reference integrity error
  intercept[org.h2.jdbc.JdbcSQLException] {
    (User AS "u").map{ u => DELETE(u).WHERE(u.email EQ "tarou@test.com").execute }
  }

  val deletePostRowsNum =  (Post AS "p").map{ p => DELETE(p).WHERE(p.author_id EQ 1).execute }
  deletePostRowsNum should be (2)

  val deleteUserRowsNum = (User AS "u").map{ u => DELETE(u).WHERE(u.email EQ "tarou@test.com").execute }
  deleteUserRowsNum should be (1)

  val userCountAfterDelete = (User AS "u").map{ u => SELECT(COUNT(u.id)).FROM(u).unique }.get
  userCountAfterDelete should be (1)
  val postCountAfterDelete = (Post AS "p").map{ p => SELECT(COUNT(p.id)).FROM(p).unique }.get
  postCountAfterDelete should be (1)
  val commentCountAfterDelete = (Comment AS "c").map{ c => SELECT(COUNT(c.id)).FROM(c).unique }.get
  commentCountAfterDelete should be (0)

}

以上でDay1は終了になります。

次回は画面開発になります。

*1:使いたかっただけで特に意味はありません。

*2:探した限り見つからなかったので、知らないだけで存在するかもしれません。