Scala PlayFramework でアプリケーション作ってみた ③ 【モデル(Skinny-ORM)編】
Scala PlayFramework でアプリケーション作ってみた。② 【Controller(View)編】 - yujiro's blog の続きです。
今回はモデルとSkinny-ORM がメインになると思います。
※ Playframework に出現する Scala言語の文法や機能などの解説も含めてしていきますが、私がScala 初心者なのでクオリティはご了承いただき、誤りがあればご指摘いただきたいです。(もちろん自分なりに調べて理解してから解説していきます。)
最初は playframework に標準についている slick
を使用しDB操作していたのですが、こちらはORMではないよう(FRM)であまり馴染みがないものだったので、Skinny-ORM
に切り替えました。
Slick (“Scala Language-Integrated Connection Kit”)はTypesafe社によってリレーショナルデータベースを簡単に扱うための、ScalaのFRM (Functional Relational Mapping)ライブラリである。まるでScalaのコレクションを扱うかのような操作でデータベースにアクセスし、データを操作出来る。SQLを直接扱うことも可能である。
slick-doc-ja 3.0 — Combined Pages
Skinny-ORM
の使い方は以下に全部のってます。これ以外全く見ていません。
参考
DB構造
以下の感じです。
※ alembic_version と言うのは気にしないでください。
モデル
以下は app/models/Memo.scala
のコードです。
package models import scalikejdbc._ import skinny._ import skinny.orm._, feature._ import org.joda.time.DateTime import scala.math.ceil import models.MemoTag import models.Tag case class Memo( id: Int, title: String, content: String, createdAt: DateTime, updatedAt: DateTime, userId: Int, user: Option[User] = None, memo_tags: Seq[MemoTag] = Nil, tags: Seq[Tag] = Nil ) { def tagStr:String = { tags.map(v => v.name ).mkString(",") } def postDate: String = { createdAt.toString("yyyy/MM/dd HH:mm:ss") } } object Memo extends SkinnyCRUDMapper[Memo] with TimestampsFeature[Memo] { self => override def defaultAlias = createAlias("m") override def tableName = "memos" override def extract(rs: WrappedResultSet, n: ResultName[Memo]) = { new Memo( id = rs.int(n.id), title = rs.string(n.title), content = rs.string(n.content), createdAt = rs.jodaDateTime(n.createdAt), updatedAt = rs.jodaDateTime(n.updatedAt), userId = rs.int(n.userId) ) } lazy val memoTagsRef = hasMany[MemoTag]( // association's SkinnyMapper and alias many = MemoTag -> MemoTag.defaultAlias, // defines join condition by using aliases on = (m, mt) => sqls.eq(m.id, mt.memoId), // function to merge associations to main entity merge = (memo, memo_tags) => memo.copy(memo_tags = memo_tags) ) lazy val tagsRef = hasManyThrough[Tag]( through = MemoTag, many = Tag, merge = (memo, tags) => memo.copy(tags = tags) ) val perPageNum = 5 belongsTo[User](User, (m, u) => m.copy(user = u)).byDefault def getForTop(userId:Int, currentPage:Int): Map[String,Any] = { val items = paginate(Pagination.page(currentPage).per(perPageNum)).where('user_id -> userId).orderBy(defaultAlias.id.desc).apply() val count = where('user_id -> userId).count() val sumPageNum = ceil(count.toDouble / perPageNum.toDouble).toInt Map( "items" -> items, "paging" -> Map( "page" -> currentPage, "perPage" -> perPageNum, "count" -> count, "sumPageNum" -> sumPageNum ) ) } def tagUpdate(id:Int, tagStr:String):Unit = { MemoTag.deleteBy(sqls.eq(MemoTag.column.memoId, id)) tagStr.split(',').foreach({v => var tag = Tag.where('name -> v).apply().headOption val tagId = if(tag == None){ Tag.createWithAttributes('name -> v) }else{ tag.get.id } MemoTag.createWithAttributes('memo_id -> id, 'tag_id -> tagId) }) } }
case class Memo
には、テーブルのカラムを定義します。またオブジェクトから使用するメソッドもここに定義して大丈夫そうです。
object Memo
はstatic なメソッドや(Memo.getForTop(~)
のように使用する)、アソシエーションやテーブル名などのテーブル定義を記述します。
実際にコントローラ等から取得したobject が持っているアトリビュートは
override def extract(rs: WrappedResultSet, n: ResultName[Memo]) = { new Memo( id = rs.int(n.id), title = rs.string(n.title), content = rs.string(n.content), createdAt = rs.jodaDateTime(n.createdAt), updatedAt = rs.jodaDateTime(n.updatedAt), userId = rs.int(n.userId) ) }
の中で case class Memo
に対して渡したものになります。
また、
case class Memo
内にメソッドを定義するとattributeのように使えます。
例えば
case class Memo( id: Int, title: String, content: String, createdAt: DateTime, updatedAt: DateTime, userId: Int, user: Option[User] = None, memo_tags: Seq[MemoTag] = Nil, tags: Seq[Tag] = Nil ) { def tagStr:String = { tags.map(v => v.name ).mkString(",") } def postDate: String = { createdAt.toString("yyyy/MM/dd HH:mm:ss") } }
として
MemosController
で以下のようにすると
def show(id: Int) = Auth { Action { implicit request => val memo = Memo.joins(Memo.tagsRef).where('id -> id).apply().headOption.get println(memo.tagStr) println(memo.postDate) Ok(views.html.memos.show(memo)) } }
aaa,aaaa 2017/09/16 09:33:55
となります。
ページング
ページング実装はモデル側(object Memo
)で一覧取得メソッドを定義して、そこでページングさせるために必要な情報を返してあげるようにしました。
def getForTop(userId:Int, currentPage:Int): Map[String,Any] = { val items = paginate(Pagination.page(currentPage).per(perPageNum)).where('user_id -> userId).orderBy(defaultAlias.id.desc).apply() val count = where('user_id -> userId).count() val sumPageNum = ceil(count.toDouble / perPageNum.toDouble).toInt Map( "items" -> items, "paging" -> Map( "page" -> currentPage, "perPage" -> perPageNum, "count" -> count, "sumPageNum" -> sumPageNum ) ) }
まず、このメソッドの返り値の型ですが、構造が異なるMap
を返すため、Map[String,Any]
としています。あまり望ましくないのかもしれません。
paginate(Pagination.page(currentPage).per(perPageNum))
の記述は、Skinny-ORMの公式にも載っているので大丈夫でしょう。
コントローラにいきます。
このメソッドを呼び出すにはログインしているユーザーのIDとページ番号を渡してあげないといけません。
ページ番号は以下のようにして取得しました。
val pageNum = request.queryString.map({ case(k,v) => (k, v.head.toInt) }).getOrElse("page", 1)
request.queryString
これでパラメータとれるのですが、Map[String,List[String]]
でとれます。
例えば ?p=2
となっている場合、
Map(p -> List(2))
という具合です。なのでmap
をつかって整形してから取得します。パラメータにpage
がなかったら 1 がとれるようにします。あとは pagingMap
という変数にページングに必要な情報を代入し、Viewに渡してあげます。
View 側では単純に以下のようにループを使用して、タグをレンダリングするようにしました。
@for(i <- 1 to pagingMap.get("sumPageNum").get) { @if(i == pagingMap.get("page").get) { <a href="javascript:void(0);" class="current"><span>@i</span></a> }else{ <a href="/memos?page=@i" class=""><span>@i</span></a> } }
以上です!
Skinny-ORMについては基本的に全部リンク先に乗っていたので実装が楽でした。