Skinny-ORMでJOINを簡単に使おう! 使い方まとめ


こんにちは@s_tsukaです。今回はSkinny-ORMについてです。最近Skinnyまわりを触っているのでまずはORMについてまとめてみます。

Skinny-ORMとは

Skinny-ORMは単体利用可能なSkinny FrameworkのORM機能です。作者はScalikejdbcとSkinnyのownerでもある@seratch_jaさんです。

http://skinny-framework.org/documentation/orm.html

Skinny-ORMはその名の通りORMなので、まあ要するにDBとのDaoとかMapperです。Scalikejdbcをラップして作られています。

Skinny-ORMは何が良いのか

人によって異なると思いますが、多分Skinny-ORMはJOINが簡単な点が最も良い点かと思います。

ScalikejdbcだとJOINしたい場合、以下のページのようにleftJoin関数を使います。

http://scalikejdbc.org/documentation/query-dsl.html

Scalikejdbcでmapper generateしたコードには当然デフォルトでjoinなどが書かれていないですし、実際追記するとなると結構面倒です。

・・・が、Skinny-ORMだと簡単にJOINができます。

http://skinny-framework.org/documentation/orm.html#associations

Associationsの所を見れば分かると思いますが、belongsToなどを使って実現します。

Skinny-ORMを使ってみる

コードはGitHubにあげておきますので、必要な方はそちらを参照すると良いかと思います。

https://github.com/tsukaby/skinny-orm-example

以下に使い方を載せておきます。libraryDependenciesとDB接続設定とMapperと最後にそれを使うコードがあればOKです。

まずはbuild.sbtにlibraryDependenciesを書きます。

libraryDependencies ++= Seq(
  "org.skinny-framework" %% "skinny-orm"      % "1.3.15",
  "com.h2database"       %  "h2"              % "1.4.+",
  "ch.qos.logback"       %  "logback-classic" % "1.1.+"
)

application.confに設定を書きます。以下の設定だとプロジェクトルートのdbフォルダ内にh2db用のファイルができますね。

development {
  db {
    default {
      driver="org.h2.Driver"
      url="jdbc:h2:file:./db/development;MODE=MySQL;AUTO_SERVER=TRUE"
      user="sa"
      password="sa"
      poolInitialSize=2
      poolMaxSize=10
      poolValidationQuery="select 1 as one"
      poolFactoryName="commons-dbcp"
    }
  }
}

scalikejdbc.global.loggingSQLAndTime.enabled=true
scalikejdbc.global.loggingSQLAndTime.singleLineMode=true

最後にMain.scalaをこんな感じで用意すればOKです。

import scalikejdbc._
import skinny.orm.SkinnyCRUDMapper

case class Person(id: Long, name: String)

object Person extends SkinnyCRUDMapper[Person] {
  override def defaultAlias = createAlias("person")

  override def extract(rs: WrappedResultSet, n: ResultName[Person]): Person = new Person(
    id = rs.get(n.id),
    name = rs.get(n.name)
  )
}

object Main {
  implicit lazy val s = AutoSession

  def main(args: Array[String]): Unit = {
    init()

    println("### Start print ###")
    Person.findAll().foreach(println)
  }

  private def init(): Unit = {
    skinny.DBSettings.initialize()

    sql"drop table if exists person".execute.apply()

    sql"create table person (id serial, name varchar(64))".execute.apply()
    sql"insert into person(id, name) values (1, 'seratch_ja')".execute.apply()
    sql"insert into person(id, name) values (2, 's_tsuka')".execute.apply()
  }
}

これで実行するとこんな感じで出力されます。

:33.584 [main] DEBUG scalikejdbc.ConnectionPool$ - Registered connection pool : ConnectionPool(url:jdbc:h2:file:./db/development;MODE=MySQL;AUTO_SERVER=TRUE, user:sa) using factory : commons-dbcp
14:48:34.301 [main] DEBUG s.StatementExecutor$$anon$1 - [SQL Execution] drop table if exists person; (5 ms)
14:48:34.344 [main] DEBUG s.StatementExecutor$$anon$1 - [SQL Execution] create table person (id serial, name varchar(64)); (2 ms)
14:48:34.347 [main] DEBUG s.StatementExecutor$$anon$1 - [SQL Execution] insert into person(id, name) values (1, 'seratch_ja'); (1 ms)
14:48:34.357 [main] DEBUG s.StatementExecutor$$anon$1 - [SQL Execution] insert into person(id, name) values (2, 's_tsuka'); (8 ms)
### Start print ###
14:48:34.843 [main] DEBUG s.StatementExecutor$$anon$1 - [SQL Execution] select person.id as i_on_person, person.name as n_on_person from person order by person.id; (0 ms)
Person(1,seratch_ja)
Person(2,s_tsuka)

ちゃんと出ました。

Scalikejdbc + mapper generatorよりもはるかにコードが少ないです。SkinnyCRUDMapperがfindAllなどの関数を持っているのでコードが少なくなるんですね。

Skinny-ORMでJOINを試す

上記のサンプル通り、コードが少なくなるメリットもありますが、何と言ってもJOINが売りかなーと思います。

ではMain.scalaを少し変えて勝手にJOINするコードにしてみます。

import scalikejdbc._
import skinny.orm.SkinnyCRUDMapper

case class Company(id: Long, name: String)

object Company extends SkinnyCRUDMapper[Company] {
  override def defaultAlias = createAlias("company")

  override def extract(rs: WrappedResultSet, n: ResultName[Company]): Company = new Company(
    id = rs.get(n.id),
    name = rs.get(n.name)
  )
}

case class Person(id: Long, name: String, company: Option[Company] = None)

object Person extends SkinnyCRUDMapper[Person] {
  override def defaultAlias = createAlias("person")

  override def extract(rs: WrappedResultSet, n: ResultName[Person]): Person = new Person(
    id = rs.get(n.id),
    name = rs.get(n.name)
  )

  belongsTo[Company](Company, (p, c) => p.copy(company = c)).byDefault
}

object Main {
  implicit lazy val s = AutoSession

  def main(args: Array[String]): Unit = {
    init()

    println("### Start print ###")
    Person.findAll().foreach(println)
  }

  private def init(): Unit = {
    skinny.DBSettings.initialize()

    sql"drop table if exists person".execute.apply()
    sql"drop table if exists company".execute.apply()

    sql"create table company (id bigint primary key, name varchar(64))".execute.apply()
    sql"create table person (id bigint primary key, name varchar(64), company_id bigint, foreign key (company_id) references company(id))".execute.apply()

    sql"insert into company(id, name) values (1, 'Company A')".execute.apply()
    sql"insert into person(id, name, company_id) values (1, 'seratch_ja', 1)".execute.apply()
    sql"insert into person(id, name, company_id) values (2, 's_tsuka', NULL)".execute.apply()
  }
}

belongsToを追加するだけです。なんて簡単なんでしょう・・・。しかもbyDefaultを付けておくと自動でJOINしてくれるという。デフォルトでJOINしたく無い場合はjoins関数を使います。詳細は公式を見ましょう。

Person(1,seratch_ja,Some(Company(1,Company A)))
Person(2,s_tsuka,None)

結果はこんな感じです。seratch_jaはcompany id = 1だったのでCompanyが設定されて、s_tsukaはNULLだったのでNoneになっています。

Skinny-ORMを単体利用しつつ、mapperを自動生成する

上記の使い方を覚えれば後は公式doc見つつなんとかなりますが、Scalikejdbc + mapper generatorユーザとしては上記のPersonやCompanyを自動生成したい!という気分になってきます。

これは可能で、Skinny Frameworkやplay連携ライブラリにはやり方が載っています。

http://skinny-framework.org/documentation/scaffolding.html#reverse-*-all-commands

https://github.com/skinny-framework/skinny-orm-in-play#model-generator

reverse-model-allで可能です。

ORM単体利用でも可能で、方法はseratchさんが教えてくれました。しかもコード付きで!感謝!

上記の例を使うとmapperをDBのスキーマに従って自動生成することができます。 skinny-taskライブラリと実際のタスクのクラスを用意して実行する感じですね。 試してみたい方は上記のseratchさんのrepoをcloneして実行すると良いと思います。もちろん上記の自分のサンプルでもlibraryDependenciesにskinny-taskを追加してTaskRunner.scalaを用意してコマンドを叩けば可能です。

3階層以上のJOINに注意

上記のサンプルだとPersonが居て、PersonはCompanyに所属している、というleft joinが1個かかる単純な例でした。 ここでCompanyに対してさらにjoinがかかっている場合どうなるでしょうか?答えはそのjoinは発動しない、です。(解決方法はあります。)新たにcompany_typeというテーブルを用意して実行してみます。 長くなるので一部省略します。大体以前と同じです。

case class CompanyType(id: Long, name: String)

object CompanyType extends SkinnyCRUDMapper[CompanyType] {
  override def defaultAlias = createAlias("company_type")

  override def extract(rs: WrappedResultSet, n: ResultName[CompanyType]): CompanyType = new CompanyType(
    id = rs.get(n.id),
    name = rs.get(n.name)
  )
}

結果はこんな感じでNoneです。Companyまでは取れていますが、CompanyTypeは取れていません。

Person(1,seratch_ja,Some(Company(1,Company A,None)))
Person(2,s_tsuka,None)

こういう場合どうするのか分からなかったのですが、またまた作者のseratchさんに教えて頂きました。ありがとうございます!

なるほど、includes、確かに公式のEager loadingの部分に書いてあります。

includesを適用したversionをGitHubに挙げました。

https://github.com/tsukaby/skinny-orm-example

Personにincludesの挙動を書いておいて、Person.includes()で動かす感じです。includesを使うとPersonとCompanyをjoinしたSQLだけでなく、CompanyとCompanyTypeをjoinしたSQLも発行されます。これらを結びつけるのがPerson内に定義しているincludesの処理の部分です。

CompanyとCompanyTypeを結びつけるSQLはCompanyのbelongsToの部分が使われます。ここにbyDefaultが付いているのでjoinが発動する感じですね。

まとめ

Skinny-ORMは単体でも利用できます。

Scalikejdbc + mapper generatorよりもコードが短くなります。

JOINが簡単に扱えます。(N+1問題が解消され効率化されます)

3階層のテーブル構造でもincludesを使うことで少ないSQLで解決できます。

みんなSkinny-ORM使いましょう!