これはScala Advent Calendar 2019の4日目の記事です。
昨日はyuyu_hfさんの「ScalaでつくるCloud Dataflowテンプレート」でした。

このブログエントリは、Scala Advent Calendar 2016用に書いた「ScalikeJDBC + circe で MySQL JSON 型を扱う」の3年越しの補足になる。
今回は以下のライブラリを使用する。
  • build.sbt
    scalaVersion := "2.13.1"
    val circeVersion       = "0.12.3"
    val scalikeJDBCVersion = "3.4.0"
    
    libraryDependencies ++= Seq(
      "io.circe"        %% "circe-core"                       % circeVersion,
      "io.circe"        %% "circe-generic"                    % circeVersion,
      "io.circe"        %% "circe-parser"                     % circeVersion,
      "mysql"           %  "mysql-connector-java"             % "8.0.18",
      "org.scalikejdbc" %% "scalikejdbc"                      % scalikeJDBCVersion,
      "org.scalikejdbc" %% "scalikejdbc-syntax-support-macro" % scalikeJDBCVersion,
      "com.h2database"  %  "h2"                               % "1.4.200" % Test
    )    
    
  • com.zaneli.jsonrdb.JsonRDB1.scala
    package com.zaneli.jsonrdb
    
    object JsonRDB1 {
      import io.circe.Json
      import io.circe.parser._
      import scalikejdbc._
    
      case class Book(id: Long, content: Json)
    
      object Book extends SQLSyntaxSupport[Book] {
        private[this] implicit val binder: TypeBinder[Json] = TypeBinder.option[String].map(_.flatMap(parse(_).toOption).getOrElse(Json.Null))
        private[this] implicit val paramBinder: ParameterBinderFactory[Json] = ParameterBinderFactory[Json] {
          value => (stmt, idx) => stmt.setString(idx, value.noSpaces)
        }
    
        private[this] val b = Book.syntax("b")
    
        override val tableName = "books"
        override val columns = Seq("id", "content")
    
        def apply(rn: ResultName[Book])(rs: WrappedResultSet): Book = autoConstruct(rs, rn)
    
        def create(book: Book)(implicit s: DBSession): Unit = withSQL {
          insert.into(Book).namedValues(
            column.id -> book.id,
            column.content -> book.content
          )
        }.update.apply()
    
        def findById(id: Long)(implicit s: DBSession): Option[Book] = {
          withSQL {
            select.from(Book as b).where.eq(column.id, id)
          }.map(Book(b.resultName)(_)).single.apply()
        }
      }
    }
    
だいたい前回と同じような内容だが、今回は create の中で scalikejdbc.ParameterBinderFactory[io.circe.Json] が要求されるのでそれも定義している。
ParameterBinderFactoryTypeBinder が必要な場合、 Binders でまとめて定義することもできるのだが、そうしていないのは後述。

このコードは勿論問題なく動く。
val book = Book(1, Json.obj(
  "name" -> Json.fromString("Scala beginner"),
  "author" -> Json.fromString("Zaneli"))
)

Book.create(book)

println(Book.findById(book.id))
// println(Book.findById(book.id)) の出力結果

Some(Book(1,{
  "name" : "Scala beginner",
  "author" : "Zaneli"
}))

さて、H2DB でも1.4.200から JSON 型が追加された。
プロダクションでは MySQL、ユニットテスト用には H2DB という構成を取っている場合、
晴れてテストでも小細工せずに JSON 型が使えるようになったようだ。
さっそく試してみよう。
H2DB でも上記コードは問題なく動…く?
// println(Book.findById(book.id)) の出力結果

Some(Book(1,"{\"name\":\"Scala beginner\",\"author\":\"Zaneli\"}"))

確かにエラーなど出ずデータの読み書きはできているようだが、なぜか content がダブルクオートがエスケープされた1つの文字列として扱われているようだ。
H2DBのJSON型のドキュメントをさらりと読んでみたが、
JSONは byte[] 型にマッピングされており、String を書き込むためには INSERT INTO TEST(ID, DATA) VALUES (?, ? FORMAT JSON) のように
FORMAT JSON をつけないといけないようだ。
しかしこれをつけるとなるとプロダクションコードと同じ実装をテストでも実行することができなそう…。

ドキュメントを頼りに、バイト配列としての書き込みを試してみる。
    import scala.io.Codec
    private[this] implicit val paramBinder: ParameterBinderFactory[Json] = ParameterBinderFactory[Json] {
      value => (stmt, idx) => stmt.setBytes(idx, value.noSpaces.getBytes(Codec.UTF8.charSet))
    }
これでどうだ。
実行してみると、1つの文字列ではなく最初のMySQLでの読み書きと同じように適切にJSONとして書き込まれたようだ。
テストコード側に引き摺られて実装を変えるのはちょっとモヤッとするがこれでいくか…と思いきや、
setBytes で書き込むと今度は MySQL で以下のエラーが発生してしまった。
[error] (run-main-0) com.mysql.cj.jdbc.exceptions.MysqlDataTruncation: Data truncation: Cannot create a JSON value from a string with CHARACTER SET 'binary'.
[error] com.mysql.cj.jdbc.exceptions.MysqlDataTruncation: Data truncation: Cannot create a JSON value from a string with CHARACTER SET 'binary'.
[error] 	at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:104)
[error] 	at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:953)
[error] 	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdateInternal(ClientPreparedStatement.java:1092)
[error] 	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdateInternal(ClientPreparedStatement.java:1040)
[error] 	at com.mysql.cj.jdbc.ClientPreparedStatement.executeLargeUpdate(ClientPreparedStatement.java:1347)
[error] 	at com.mysql.cj.jdbc.ClientPreparedStatement.executeUpdate(ClientPreparedStatement.java:1025)

困った…困ったが何とかしよう。
観念してテストでも MySQL を使うというのも一つの選択肢だが、力技で何とかしたのがこちら。
  • com.zaneli.jsonrdb.JsonRDB2.scala
    package com.zaneli.jsonrdb
    
    import scala.io.Codec
    
    object JsonRDB2 {
      import io.circe.Json
      import io.circe.parser._
      import scalikejdbc._
    
      case class Book(id: Long, content: Json)
    
      object Book extends SQLSyntaxSupport[Book] {
        private[this] implicit val binder: TypeBinder[Json] = TypeBinder.option[String].map(_.flatMap(parse(_).toOption).getOrElse(Json.Null))
    
        private[this] val b = Book.syntax("b")
    
        override val tableName = "books"
        override val columns = Seq("id", "content")
    
        def apply(rn: ResultName[Book])(rs: WrappedResultSet): Book = autoConstruct(rs, rn)
    
        def create(book: Book)(implicit s: DBSession, json: JsonParameterBinder): Unit = withSQL {
          import json.provide
          insert.into(Book).namedValues(
            column.id -> book.id,
            column.content -> book.content
          )
        }.update.apply()
    
        def findById(id: Long)(implicit s: DBSession): Option[Book] = {
          withSQL {
            select.from(Book as b).where.eq(column.id, id)
          }.map(Book(b.resultName)(_)).single.apply()
        }
      }
    
      trait JsonParameterBinder {
        implicit def provide: ParameterBinderFactory[Json]
      }
      class JsonStringParameterBinder extends JsonParameterBinder {
        override implicit lazy val provide: ParameterBinderFactory[Json] = ParameterBinderFactory[Json] {
          value => (stmt, idx) => stmt.setString(idx, value.noSpaces)
        }
      }
      object JsonByteArrayParameterBinder extends JsonParameterBinder {
        override implicit lazy val provide: ParameterBinderFactory[Json] = ParameterBinderFactory[Json] {
          value => (stmt, idx) => stmt.setBytes(idx, value.noSpaces.getBytes(Codec.UTF8.charSet))
        }
      }
    }  
    
ParameterBinderFactory[Json] が要求されるメソッドに implicit parameter として JsonParameterBinder を渡すようにしてみた。
サンプルコードのため一箇所にまとめているが、本当は JsonByteArrayParameterBinder のほうはテストコード側(src/test 以下)に置くべきで、
JsonStringParameterBinder のほうは DI でいい感じにプロダクションコードでのみ注入されるよう(使用しているDIライブラリの必要に応じて)アノテーションなどを書く。
テスト側では適宜 implicit val j: JsonParameterBinder = JsonByteArrayParameterBinder を書き、
java.sql.PreparedStatementsetString または setBytes を呼ぶ箇所のみ切り替えることができた。
TypeBinder は共通で問題なく、ParameterBinderFactory のみ分けたかったので Binders でまとめて定義はしなかった。

一件落着…ではあるが、H2DB で MODE=MySQL を設定した場合にはこの辺の挙動が合っていてほしかったなぁ…という気がしないでもない。

明日はtakat0-h0rikosh1さんの「Dotty で入る Opaque Type Aliases について見ていく」です。

Copyright© 2011-2021 Shunsuke Otani All Right Reserved .