CLOVER🍀

That was when it all began.

JPQLとString Interpolationを使って遊んでみる

この前、ScalaのString Interpolationを初めて自分で定義してみたので、ちょっと練習的にネタを書いてみました。

ターゲットは、JPQLです。

JPQLを使ってクエリを投げる時には、

  em.createQuery("SELECT u FROM User u WHERE age > :age", User.class)
    .setParameter("age", "...")
    .getResultList();

みたいなことを書くわけですが(この例では名前付きパラメータですが、インデックスベースの位置指定パラメータ「?1」や「?」などでもOKです)、これをちょっと変えてみたいなぁと思いまして。

というわけで、練習を兼ねてString Interpolationを使ってもうちょっと簡単に書けないか試してみました。

使い方のイメージとしては、

  jpql"SELECT u FROM User u WHERE age > $age"
    .asQuery(classOf[User])(entityManager)
    .getResultList

みたいな感じで?

では、いってみましょう。

依存関係の定義

JPAの実装にはHibernateを、データベースにはMySQLを、テスティングフレームワークにはScalaTestを使用することにしたので、依存関係の定義はこんな感じです。
build.sbt

name := "jpql-string-interpolation"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.3"

organization := "littlewings"

fork in run := true

libraryDependencies ++= Seq(
  "org.hibernate" % "hibernate-entitymanager" % "4.2.8.Final",
  "mysql" % "mysql-connector-java" % "5.1.26" % "runtime",
  "org.scalatest" %% "scalatest" % "2.0" % "test"
)

jpql補間子を定義する

それでは、JPQL用の補間子を定義します。
src/main/scala/javaee6/web/stringinterpolation/JpqlStringInterpolation.scala

package javaee6.web.stringinterpolation

object JpqlStringInterpolation {
  implicit class JpqlStringInterpolationWrapper(val sc: StringContext) extends AnyVal {
    def jpql(params: Any*): Jpql =
      Jpql.bind(sc.parts.map(StringContext.treatEscapes).mkString("?"))((1 to params.size).zip(params): _*)
  }
}

これで、

  jpql"SELECT u FROM User u WHERE id = $id"

みたいなことを書くと、

  "SELECT u FROM User u WHERE id = ?"

というようなクエリになります。このクエリと、実際にバインドされたパラメータとインデックスは以下のクラスに渡します。
src/main/scala/javaee6/web/stringinterpolation/Jpql.scala

package javaee6.web.stringinterpolation

import javax.persistence.{EntityManager, Query}

object Jpql {
  def bind(queryAsString: String)(params: (Int, Any)*): Jpql =
    new Jpql(queryAsString, params: _*)
}

class Jpql(queryAsString: String, params: (Int, Any)*) {
  def asQuery[T](resultClass: Class[T])(implicit entityManager: EntityManager): Query =
    params.foldLeft(entityManager.createQuery(queryAsString, resultClass)) {
      case (query, (index, value)) => query.setParameter(index, value)
    }

  def stripMargin: Jpql =
    Jpql.bind(queryAsString.stripMargin)(params: _*)

  def stripMargin(marginChar: Char): Jpql =
    Jpql.bind(queryAsString.stripMargin(marginChar))(params: _*)
}

実際に、JPAのQueryに変換するのは、こちらのクラスです。asQueryでJPAのQueryに変換しますが、EntityManagerはImplicit Parameterとするようにしました。

あと、stripMarginくらいは使えるようにしておきました。

JPAを使う準備

永続性ユニットと、利用するEntityの定義。
src/main/resources/META-INF/persistence.xml

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://java.sun.com/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
             version="2.0">
  <persistence-unit name="javaee6.web.pu" transaction-type="RESOURCE_LOCAL">
    <provider>org.hibernate.ejb.HibernatePersistence</provider>
    <properties>
      <property name="hibernate.dialect" value="org.hibernate.dialect.MySQL5InnoDBDialect" />
      <property name="javax.persistence.jdbc.driver" value="com.mysql.jdbc.Driver" />
      <property name="javax.persistence.jdbc.url" value="jdbc:mysql://localhost/test?useUnicode=true&amp;characterEncoding=utf8" />
      <property name="javax.persistence.jdbc.user" value="kazuhira" />
      <property name="javax.persistence.jdbc.password" value="password" />
      <property name="hibernate.show_sql" value="true" />
      <property name="hibernate.format_sql" value="true" />
    </properties>
  </persistence-unit>
</persistence>

src/main/scala/javaee6/web/entity/User.scala

package javaee6.web.entity

import scala.beans.BeanProperty

import java.util.Objects
import javax.persistence.{Column, Entity, Id, Table, Version}

object User {
  def apply(id: Int, firstName: String, lastName: String, age: Int): User = {
    val user = new User
    user.id = id
    user.firstName = firstName
    user.lastName = lastName
    user.age = age
    user
  }
}

@SerialVersionUID(1L)
@Entity
@Table(name = "user")
class User extends Serializable {
  @Id
  @BeanProperty
  var id: Int = _

  @Column(name = "first_name")
  @BeanProperty
  var firstName: String = _

  @Column(name = "last_name")
  @BeanProperty
  var lastName: String = _

  @Column(name = "age")
  @BeanProperty
  var age: Int = _

  @Column(name = "version_no")
  @BeanProperty
  var versionNo: Int = _

  override def toString(): String =
    s"id = $id, firstName = $firstName, lastName = $lastName, age = $age, versionNo = $versionNo"

  override def equals(other: Any): Boolean = other match {
    case ou: User => id == ou.id && firstName == ou.firstName && lastName == ou.lastName && age == ou.age
    case _ => false
  }

  override def hashCode: Int =
    Objects.hashCode(id, firstName, lastName, age)
}

使うテーブルの定義は、こちら。

mysql> SHOW CREATE TABLE user;
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                                           |
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| user  | CREATE TABLE `user` (
  `id` int(9) NOT NULL DEFAULT '0',
  `first_name` varchar(10) DEFAULT NULL,
  `last_name` varchar(10) DEFAULT NULL,
  `age` int(3) DEFAULT NULL,
  `version_no` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 |
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.05 sec)

使ってみる

では、ここまで定義したjpql補間子とEntityを使ったテストコードを書いてみます。

import文と初期化処理。

package javaee6.web.stringinterpolation

import scala.collection.JavaConverters._

import javax.persistence.{EntityManager, EntityManagerFactory, Persistence}

import javaee6.web.entity.User
import javaee6.web.stringinterpolation.JpqlStringInterpolation._

import org.scalatest.{BeforeAndAfterAll, FunSpec}
import org.scalatest.Matchers._

class JpqlStringInterpolationSpec extends FunSpec with BeforeAndAfterAll {
  override def beforeAll(): Unit = {
    val emf = Persistence.createEntityManagerFactory("javaee6.web.pu")
    val em = emf.createEntityManager
    val tx = em.getTransaction

    tx.begin()
    em.createNativeQuery("TRUNCATE TABLE user").executeUpdate()
    em.flush()
    tx.commit()

    tx.begin()
    Array(User(1, "カツオ", "磯野", 11),
          User(2, "ワカメ", "磯野", 9),
          User(3, "タラオ", "フグ田", 3)).foreach(em.persist)
    tx.commit()
  }

常に、データ再登録。

とりあえず、普通にJPQLを使ってみますか。

  describe("Standard Jpql") {
    it("find One id equals & literal") {
      val emf = Persistence.createEntityManagerFactory("javaee6.web.pu")
      val em = emf.createEntityManager

      em.createQuery("SELECT u FROM User u WHERE id = 1", classOf[User])
        .getSingleResult should be (User(1, "カツオ", "磯野", 11))
    }
  }

クエリには、リテラルを直接埋め込んでいます。

このコードを実行すると、HibernateのログにはこういうSQLが出力されます。

Hibernate: 
    select
        user0_.id as id1_0_,
        user0_.age as age2_0_,
        user0_.first_name as first_na3_0_,
        user0_.last_name as last_nam4_0_,
        user0_.version_no as version_5_0_ 
    from
        user user0_ 
    where
        user0_.id=1

では、続いて同じようなことを定義したjpql補間子を使ってみると

  describe("Jpql String Interpolation Spec") {
    describe("select") {
      it("find One id equals & numeric literal") {
        val emf = Persistence.createEntityManagerFactory("javaee6.web.pu")
        implicit val em = emf.createEntityManager

        jpql"SELECT u FROM User u WHERE id = ${1}"
          .asQuery(classOf[User])
          .getSingleResult should be (User(1, "カツオ", "磯野", 11))
      }

バインド変数が適用された形で実行されます。また、EntityManagerを束縛している変数には、「implicit」を付与しています。

Hibernate: 
    select
        user0_.id as id1_0_,
        user0_.age as age2_0_,
        user0_.first_name as first_na3_0_,
        user0_.last_name as last_nam4_0_,
        user0_.version_no as version_5_0_ 
    from
        user user0_ 
    where
        user0_.id=?

変数を使ってみます。

      it("find One id equals And age equals") {
        val emf = Persistence.createEntityManagerFactory("javaee6.web.pu")
        implicit val em = emf.createEntityManager

        val id = 2
        val age = 9

        jpql"SELECT u FROM User u WHERE id = $id AND age = $age"
          .asQuery(classOf[User])
          .getSingleResult should be (User(2, "ワカメ", "磯野", 9))
      }

raw string literal。

      it("find One id,age & raw string literal, stripMargin") {
        val emf = Persistence.createEntityManagerFactory("javaee6.web.pu")
        implicit val em = emf.createEntityManager

        val id = 2
        val age = 9

        jpql"""|SELECT u
               | FROM User u
               | WHERE id = $id
               | AND age = $age"""
          .stripMargin
          .asQuery(classOf[User])
          .getSingleResult should be (User(2, "ワカメ", "磯野", 9))
      }

複数件。

      it("find Collection age > 5") {
        val emf = Persistence.createEntityManagerFactory("javaee6.web.pu")
        implicit val em = emf.createEntityManager

        val age = 5

        jpql"SELECT u FROM User u WHERE age > $age"
          .asQuery(classOf[User])
          .getResultList should contain theSameElementsAs (Array(User(1, "カツオ", "磯野", 11),
                                                                 User(2, "ワカメ", "磯野", 9)))
      }

ちゃんと、テストにもパスしますよ。

> test

〜省略〜

[info] JpqlStringInterpolationSpec:
[info] Standard Jpql
[info] - find One id equals & literal
[info] Jpql String Interpolation Spec
[info]   select
[info]   - find One id equals & numeric literal
[info]   - find One id equals And age equals
[info]   - find One id,age & raw string literal, stripMargin
[info]   - find Collection age > 5
[info] Run completed in 5 seconds, 708 milliseconds.
[info] Total number of tests run: 5
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 5, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.

String Interpolationなので、こういう風に存在しない変数を書いたりすると

        val id = 2
        val age = 9

        jpql"SELECT u FROM User u WHERE id = $id AND age = $ag"

コンパイルエラーになります。

src/test/scala/javaee6/web/stringinterpolation/JpqlStringInterpolationSpec.scala:59: not found: value ag
[error]         jpql"SELECT u FROM User u WHERE id = $id AND age = $ag"
[error]                                                             ^
[error] one error found
[error] (test:compile) Compilation failed

バインドする手間もなくて、スッキリ。

と言いたいところですが、NamedQueryには手が出せないとか、インデックスベースのバインド変数の指定はJPA的にはよろしくないらしくて、Hibernateからは警告されたりします。

WARN: [DEPRECATION] Encountered positional parameter near line 1, column 53.  Positional parameter are considered deprecated; use named parameters or JPA-style positional parameters instead.

あと、JPAを使っていてAPIとして気になるのは、QueryのgetResultListのシグネチャが

java.util.List getResultList()

なことですかね…。ジェネリクス使えるんだから、Listとかにすればいいのに…。

まあ、ちょっと遊んでみました的なネタでした。

ソースは、こちらに置いています。

https://github.com/kazuhira-r/javaee6-scala-examples/tree/master/jpql-string-interpolation

こういうのの活用は、先人であるScalikeJDBCで文化を学んできた方がいいのかなぁ…。

ScalikeJDBC
http://scalikejdbc.org/