これはScala Advent Calendar 2019の4日目の記事です。
昨日はyuyu_hfさんの「ScalaでつくるCloud Dataflowテンプレート」でした。
このブログエントリは、Scala Advent Calendar 2016用に書いた「ScalikeJDBC + circe で MySQL JSON 型を扱う」の3年越しの補足になる。
昨日はyuyu_hfさんの「ScalaでつくるCloud Dataflowテンプレート」でした。
このブログエントリは、Scala Advent Calendar 2016用に書いた「ScalikeJDBC + circe で MySQL JSON 型を扱う」の3年越しの補足になる。
今回は以下のライブラリを使用する。
このコードは勿論問題なく動く。
さて、H2DB でも1.4.200から JSON 型が追加された。
プロダクションでは MySQL、ユニットテスト用には H2DB という構成を取っている場合、
晴れてテストでも小細工せずに JSON 型が使えるようになったようだ。
さっそく試してみよう。
H2DB でも上記コードは問題なく動…く?
確かにエラーなど出ずデータの読み書きはできているようだが、なぜか
H2DBのJSON型のドキュメントをさらりと読んでみたが、
JSONは
しかしこれをつけるとなるとプロダクションコードと同じ実装をテストでも実行することができなそう…。
ドキュメントを頼りに、バイト配列としての書き込みを試してみる。
実行してみると、1つの文字列ではなく最初のMySQLでの読み書きと同じように適切にJSONとして書き込まれたようだ。
テストコード側に引き摺られて実装を変えるのはちょっとモヤッとするがこれでいくか…と思いきや、
困った…困ったが何とかしよう。
観念してテストでも MySQL を使うというのも一つの選択肢だが、力技で何とかしたのがこちら。
サンプルコードのため一箇所にまとめているが、本当は
テスト側では適宜
一件落着…ではあるが、H2DB で
明日はtakat0-h0rikosh1さんの「Dotty で入る Opaque Type Aliases について見ていく」です。
- 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]
が要求されるのでそれも定義している。ParameterBinderFactory
と TypeBinder
が必要な場合、 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.PreparedStatement
の setString
または setBytes
を呼ぶ箇所のみ切り替えることができた。TypeBinder
は共通で問題なく、ParameterBinderFactory
のみ分けたかったので Binders
でまとめて定義はしなかった。一件落着…ではあるが、H2DB で
MODE=MySQL
を設定した場合にはこの辺の挙動が合っていてほしかったなぁ…という気がしないでもない。明日はtakat0-h0rikosh1さんの「Dotty で入る Opaque Type Aliases について見ていく」です。