Scala用のタイプセーフなSQLビルダを作ってみた

Scalaには様々なデータベースアクセスライブラリがありますが、Scalaの型安全性を活かしたクエリビルダはコンパイル時にクエリの間違いを検出することができるため非常に有難いものです。しかし、私見ですが、タイプセーフなクエリビルダは歴史あるトピックにも関わらずScalaにおいてはこれだというデファクトスタンダードが存在しません。

SlickやQuillは実行時 or マクロかという違いはあるもののASTからSQLへの変換を行っており、特にSlickに関しては変換後のSQLがかなり冗長なものになってしまうケースが多いです。また、変換処理も複雑で簡単に最適化できるようなものでもありません。一方国内を中心に人気の高いScalikeJDBCのQueryDSLは生成されるSQLが予想しやすいものの型付けの面で弱い部分があり、プログラマが意識しないと実行時エラーを引き起こす可能性のあるコードを書くことが割と簡単にできてしまいます。

Slickほどの柔軟性はなくてもいいので同じくらい型安全で、なおかつScalikeJDBCのように予測可能なSQLが生成されるSQLビルダを作ることはできないだろうか?という実験のためシンプルなSQLビルダを作ってみました。

github.com

まずはテーブルの定義とマッピングするケースクラスを以下のような形で用意しておきます。自動生成ツールなどはまだないので手で書かなければなりません。ケースクラスへのマッピングのあたりなどはもっとエレガントな仕組みにできそうな気もしますが、そこを頑張るのが目的ではないのでいったん考えないことにします。

import com.github.takezoe.tranquil._

case class User(userId: String, userName: String, companyId: Option[Int])

class Users(val alias: Option[String]) extends TableDef[User] {
  val tableName = "USERS"
  val userId    = new Column[String](alias, "USER_ID")
  val userName  = new Column[String](alias, "USER_NAME")
  val companyId = new NullableColumn[Int](alias, "COMPANY_ID")
  val columns = Seq(userId, userName, companyId)

  override def toModel(rs: ResultSet): User = {
    User(userId.get(rs), userName.get(rs), companyId.getOpt(rs))
  }
}

object Users {
  def apply() = {
    val users = new Users(None)
    new SingleTableAction[Users](users)
  }
  def apply(alias: String) = {
    val users = new Users(Some(alias))
    new Query[Users, Users, User](users, users, users.toModel _)
  }
}

case class Company(companyId: Int, companyName: String)

class Companies(val alias: Option[String]) extends TableDef[Company] {
  val tableName = "COMPANIES"
  val companyId   = new Column[Int](alias, "COMPANY_ID")
  val companyName = new Column[String](alias, "COMPANY_NAME")
  val columns = Seq(companyId, companyName)

  override def toModel(rs: ResultSet): Company = {
    Company(companyId.get(rs), companyName.get(rs))
  }
}

object Companies {
  def apply() = {
    val companies = new Companies(None)
    new SingleTableAction[Companies](companies)
  }
  def apply(alias: String) = {
    val companies = new Companies(alias)
    new Query[Companies, Companies, Company](companies, companies, companies.toModel _)
  }
}

すると以下のようにSlickっぽい感じでタイプセーフにクエリを組み立てることができます。leftJoinしたテーブルは自動的にOptionマッピングされます。

val conn: java.sql.Connection = ...

// SELECT
val users: Seq[(User, Option[Company])] =
  Users("u")
    .leftJoin(Companies("c")){ case u ~ c => u.companyId eq c.companyId }
    .filter { case u ~ c => (u.userId eq "takezoe") || (u.userId eq "takezoen") }
    .sortBy { case u ~ c => u.userId asc }
    .list(conn)

出力されるSQLはこんな感じです。leftJoinって書いたらちゃんとLEFT JOINになります。こんなに素晴らしいことはない…。

SELECT
  u.USER_ID      AS u_USER_ID,
  u.USER_NAME    AS u_USER_NAME,
  u.COMPANY_ID   AS u_COMPANY_ID,
  c.COMPANY_ID   AS c_COMPANY_ID,
  c.COMPANY_NAME AS c_COMPANY_NAME
FROM USERS u
LEFT JOIN COMPANIES c ON u.COMPANY_ID = c.COMPANY_ID
WHERE (u.USER_ID = ? OR u.USER_ID = ?)
ORDER BY u.USER_ID ASC

一応INSERTUPDATEDELETEもこんな感じでできるようにしてみました。このへんももう少し工夫の余地はありそうですが、まあ今回はおまけみたいなものです。

// INSERT
Users()
  .insert { u => 
    (u.userId -> "takezoe") ~ 
    (u.userName -> "Naoki Takezoe")
  }
  .execute(conn)

// UPDATE
Users()
  .update(_.userName -> "N. Takezoe")
  .filter(_.userId eq "takezoe")
  .execute(conn)

// DELETE
Users()
  .delete()
  .filter(_.userId eq "takezoe")
  .execute(conn)

今のところ以下のようなことはできません。めんどくさいのでやってないだけというものもありますが、集約や関数呼び出しが必要な場合は生SQLを書くと割り切ってもよいかもと思っています。

  • SQLでの関数呼び出し
  • 特定のカラムだけSELECTする
  • GROUP BY、HAVING
  • ページング(LIMIT、OFFSET的なやつ)

DB毎の方言を吸収する仕組みもないし、パラメータの型も文字列と数値くらいしか対応してない(型クラスで拡張可能)ので、とても実用的とは言えないものですが、ScalaでまともなSQLが生成できるタイプセーフなクエリビルダを作るのはそんなに難しくなさそうということはわかりました。

SlickもDBIOはともかくクエリビルダに関しては変に抽象化せずにこういうものでよかったのではと思うのですが、まあ今更言っても詮のないことです。