seratch's weblog in Japanese

About Scala, Java and Ruby programming in Japaense. If you need English information, go to http://blog.seratch.net/

ScalikeJDBC の新しい DSL

ScalikeJDBC に新しく SQL を組み立てるための DSL を追加しようとしています。次のリリースバージョンを 1.6.0 とし、scalikejdbc-interpolation でこの DSL が利用可能になる想定です。手前味噌ですが「なかなか便利なのでは?」と思っています。

(追記:5/16) 1.6.0 としてリリースしました。お試しください。

http://git.io/scalikejdbc

sandbox で試す

どういったものかというと、実際に動かしてみるのがわかりやすいと思います。まず sandbox をダウンロードしてみてください。

git clone git://github.com/seratch/scalikejdbc.git
cd scalikejdbc/sandbox
sbt console 

sbt console を起動すると、簡単なデータが準備されているので、以下のようなクエリを投げることができます。

https://github.com/seratch/scalikejdbc/tree/develop/sandbox

// sbt console の initialCommands で読み込み済み
// implicit val session = AutoSession
// val (u, g, gm, c) = (User.syntax("u"), Group.syntax("g"), GroupMember.syntax("gm"), Company.syntax("c"))

val alice: Option[User] = withSQL {
  select
    .from(User as u)
    .leftJoin(Company as c).on(u.companyId, c.id)
    .where.eq(u.name, "Alice")
}.map(User(u, c)).single.apply()

実行結果はこんな感じ。

scala> val alice: Option[User] = withSQL {
     |   select
     |     .from(User as u)
     |     .leftJoin(Company as c).on(u.companyId, c.id)
     |     .where.eq(u.name, "Alice")
     | }.map(User(u, c)).single.apply()
[run-main] INFO scalikejdbc.StatementExecutor$$anon$1 - SQL execution completed

  [SQL Execution]
   select u.id as i_on_u, u.name as n_on_u, u.company_id as ci_on_u, c.id as i_on_c, c.name as n_on_c from users u left join companies c on u.company_id = c.id where u.name = 'Alice'; (12 ms)

  [Stack Trace]
    ...
    $line7.$read$$iw$$iw$$iw$$iw$$iw$$iw$$iw$$iw$.<init>(<console>:29)
    $line7.$read$$iw$$iw$$iw$$iw$$iw$$iw$$iw$$iw$.<clinit>(<console>)
    $line7.$eval$.<init>(<console>:7)
    $line7.$eval$.<clinit>(<console>)
    $line7.$eval.$print(<console>)
    sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
    sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    java.lang.reflect.Method.invoke(Method.java:601)
    scala.tools.nsc.interpreter.IMain$ReadEvalPrint.call(IMain.scala:731)
    scala.tools.nsc.interpreter.IMain$Request.loadAndRun(IMain.scala:980)
    scala.tools.nsc.interpreter.IMain.loadAndRunReq$1(IMain.scala:570)
    scala.tools.nsc.interpreter.IMain.interpret(IMain.scala:601)
    scala.tools.nsc.interpreter.IMain.interpret(IMain.scala:565)
    scala.tools.nsc.interpreter.ILoop.reallyInterpret$1(ILoop.scala:745)
    ...

alice: Option[User] = Some(User(1,Some(Alice),None,None))

これは、今まででいう以下と同じです。

val name = "Alice"
val alice: Option[User] = sql"""
  select ${u.result.*}, ${c.result.*}
  from ${User.as(u)} left join ${Company.as(c)} on ${u.companyId} = ${c.id}
  where ${u.name} = ${name}
""".map(User(u, c)).single.apply()

もう少し複雑なクエリだとこんな感じになります。

val groups: List[Group] = withSQL {
  select
    .from(GroupMember as gm)
    .innerJoin(User as u).on(u.id, gm.userId)
    .innerJoin(Group as g).on(g.id, gm.groupId)
    .leftJoin(Company as c).on(u.companyId, c.id)
    .where.eq(g.id, 1)
    .orderBy(u.id)
    .limit(5)
    .offset(0)
}.one(Group(g))
 .toMany(User.opt(u, c))
 .map { (g, members) => g.copy(members = members) }
 .list
 .apply()

こちらは慣れが必要かもしれませんが、サブクエリなども使えます。

https://github.com/seratch/scalikejdbc/blob/develop/scalikejdbc-interpolation/src/test/scala/scalikejdbc/QueryInterfaceSpec.scala

case class Order(id: Int, productId: Int, accountId: Option[Int])
case class Product(id: Int, name: Option[String], price: Int)
object Order extends SQLSyntaxSupport[Order]
object Product extends SQLSyntaxSupport[Product]

import SQLSyntax.{ sum, gt }

val (o, p) =(Order.syntax("o"), Product.syntax("p"))
val x = SubQuery.syntax("x", o.resultName, p.resultName)

val preferredClients: List[(Int, Int)] = withSQL {
  select(sqls"${x(o).accountId} id", sqls"${sum(x(p).price)} as amount")
    .from(select.from(Order as o).innerJoin(Product as p).on(o.productId, p.id).as(x))
    .groupBy(x(o).accountId)
    .having(gt(sum(x(p).price), 300))
    .orderBy(sqls"amount")
}.map(rs => (rs.int("id"), rs.int("amount"))).list.apply()

これまでは select 文の例でしたが、更新系も以下のようにサポートしています。

// withSQL{...}.update.apply() と applyUpdate() は同じ
withSQL(insert.into(User).values(1, "Alice")).update.apply()
applyUpdate(insert.into(User).values(1, "Alice"))

applyUpdate { 
  update(User as u)
    .set(u.name -> "Bob", u.updatedAt -> DateTime.now)
    .where.eq(u.id, 1) 
}
applyUpdate { delete.from(User as u).where.isNull(u.name) }

もしかすると DDL もある程度サポートすると、それはそれで試みとしては面白そうではありますが、今のところあまり必要性は感じていません。

新しい DSL の概要

といった感じで具体例を見てきましたが、新しい DSL の概要について説明します。

今回追加したのは withSQL {...} で SQLInterpolation と同様に SQL 型のオブジェクトを生成して返す部分のみです。

生成した後の処理は内部的には SQLInterpolation として動作しますので、既に実案件でいくつか実績のある枯れた実装をそのまま使っています(というか ScalikeJDBC は DBSession しかない実装から始まり、これまでもずっとそういうアプローチで機能拡張してきました)。

実際、何をやっているかというとメソッドを呼ぶ度に SQLSyntax をどんどん append したものに更新していくだけです。ここは SQLBuilder というクラスでやっています。

それを最終的に withSQL {...} で囲むか、#toSQL を呼び出すと SQL[A, NoExtractor] 型として返されるので、そこからはこれまでの ScalikeJDBC と同様に execute/update または ResultSet から取り出しを行うことになります。

モチベーション

この新しい DSL をなぜつくったかというと、前々から思っていたこととして SQLInterpolation は十分便利ではあるものの、もし、いわゆる流れるようなインタフェースで書くことができたなら、コンパイラチェックによってタイプミスにも気づきやすくなるだろうし、IDE を使っている場合は補完機能の恩恵も受けられるだろうなという問題意識がありました。

あと、API の見た目がフレンドリーであることは重要だと思っていて、この新しい API はより読みやすく、そして書きやすくなっていると思っています(SQLInterpolation は ${...} を結構たくさん書かないといけなくて、場合によってはストレスになります)。

一方で ScalikeJDBC は「実務で使ったときにライブラリの都合で手詰まりにならないものを提供したい」というのがモチベーションとしてあるので、それを損なうような検討漏れがないかを確認しているところです。

SQLInterpolation オワコン?

もちろん内部的には使われているので廃止はされませんが、直接使うケースは減るのではないかと思います。シンプルなジョインクエリくらいであれば、基本的には新しい DSL を使う方がよいと思います。

ただ、複雑なクエリを書く場合は SQLInterpolation を直接書いた方がスムーズなケースがあると思います。

フィードバックをお待ちしています

1.6.0 のリリース日は未定ですが、自分の中では特に大きな問題点は見えていないので、このまま行けば現時点での実装をベースにリリースすることになると思います。

特に SQLBuilder の API で想定漏れが怖いので、リリース前に第三者のフィードバックをいただけると大変助かります。特に使ってみようと思われている方、フィードバックいただければと思います。

QueryInterfaceSpec.scala

scalikejdbc/SQLInterpolation.scala

scalikejdbc/interpolation/SQLSyntax.scala