🛀

ScalikeJDBC のバルクインサートやバッチ更新よもやま

2023/09/17に公開

前書き

この記事は @kijuky さんの ScalikeJDBCでバルクインサート&バルクアップデート
https://qiita.com/kijuky/items/261477accfa8ee52eb6f
という記事に触発されて書きました。
上記記事では主に SQL Interpolation での解決をされていらしたので、QueryDSL を使った時のヘルパーについて紹介します。

この記事で使用する言語とライブラリのバージョンは以下の通りです。

  • Scala 3.3.1
  • ScalikeJDBC 4.0.0

JDBC のバッチ更新API

JDBC ではもともとバルクインサート等とは別に複数の更新操作を一度に行うための機能を備えています。
ScalikeJDBC は JDBC の自然なラッパーであるため、当然この API も呼び出すことができます

import scalikejdbc._

DB localTx { implicit session =>
  val batchParams: Seq[Seq[Any]] = (2001 to 3000).map(i => Seq(i, "name" + i))
  sql"insert into employee (id, name) values (?, ?)".batch(batchParams: _*).apply()
}

PostgreSQL などのバッチ更新に対応している RDBMS を使う場合、バルクインサート等の代わりにこちらの API も検討してみるとよいかもしれません。

MySQL Connector/J の rewriteBatchedStatements

話がややこしいのは MySQL を利用している場合ですね。

MySQL では単純にバッチ更新API を利用するとバルクインサート等に比べて極端にパフォーマンスが劣化する場合があります。
そのためバッチ更新API を使わずにバルクインサート等を使いたいというケースが度々発生します。

もちろんコードでバルクインサート等を書いてもいいのですが、なんと MySQL の JDBC Driver である MySQL Connector/J ではバッチ更新APIで呼び出されたクエリ内容を裏側でバルクインサート等に書き換えるという機能が存在しています。

この機能を使うためのプロパティが rewriteBatchedStatements です。

なかなか豪快な機能ですね。もちろんトレードオフもあるので常に使えるとは限りませんが、状況に応じて利用を検討してもいいかもしれません。

QueryDSL を使った方がよい場合

SQL Interpolation で問題ない場合は SQL Interpolation をそのまま使ってしまえばいいのですが、アプリケーションが複雑になってくると QueryDSL を使った方がよいケースというのも出てきます。

その最たる例がドメインプリミティブを使うケースですね。

例えば Employee エンティティの Id や Name に、JDBC が直接扱える IntString などではなくアプリケーションで独自の型を定義している場合を考えてみましょう。

case class EmployeeId(value: Int)
case class EmployeeName(value: String)

case class Employee(id: EmployeeId, name: EmployeeName)

これを SQL Interpolation で扱うためにはきちんと JDBC が扱えるデータ型に明示的に変換する必要があります。

val emp: Employee = ...
DB localTx { implicit session =>
  sql"insert into employee (id, name) values (${emp.id.value}, ${emp.name.value})"
    .update.apply()
}

明示的に .value を呼び出す必要がある訳ですね。

そして SQL Interpolation の制約上、この .value 呼び出しをうっかり忘れてしまってもコンパイルは通ります。

val emp: Employee = ...
DB localTx { implicit session =>
  // ミスしてもコンパイルエラーにならない!
  sql"insert into employee (id, name) values (${emp.id}, ${emp.name})"
    .update.apply()
}

その結果、実行時にエラーになったりもっと悪いケースではエラーにならずに意図と異なる値がDBに格納されたりします。困ってしまいますね。

そしてこれを防ぐ仕組みが QueryDSL には存在しています。それが ParameterBinderFactory という型クラスですね。

import scalikejdbc._

given ParameterBinderFactory[EmployeeId] = 
  summon[ParameterBinderFactory[Int]].contramap(_.value)

given ParameterBinderFactory[EmployeeName] = 
  summon[ParameterBinderFactory[String]].contramap(_.value)

この様にそれぞれのドメインプリミティブに対し ParameterBinderFactory を定義しておくと、以下のような QueryDSL で .value のような変換コードを書く必要がなくなります。

val emp: Employee = ...

val column = Employee.column
DB localTx { implicit session =>
  withSQL {
    insert.into(Employee).namedValues(
      column.id   -> emp.id, // .value が不要
      column.name -> emp.name
    )
  }.update.apply()
}

また、仮に ParameterBinderFactory を定義しなかった場合、上記コードはコンパイルエラーになります。安心安全ですね。

BatchParamsBuilder

そして、上記の ParameterBinderFactory をバッチ更新API で利用するためのヘルパーとして BatchParamsBuilder というクラスがあります。

val emps: Seq[Employee] = ...

val column = Employee.column
val builder = BatchParamsBuilder {
  emps.map { emp => 
    Seq(
      column.id   -> emp.id,   // ここで ParameterBinderFactory が使われる
      column.name -> emp.name  // 見つからなければコンパイルエラー
    )
  }
}
DB localTx { implicit session =>
  withSQL {
    insert.into(Employee).namedValues(builder.columnsAndPlaceholders: _*)
  }.batch(builder.batchParams: _*).apply()
}

これを利用することで、ドメインプリミティブを活用するような規模のアプリケーションでも安全にバッチ更新API を利用することができます。

ScalikeJDBC にて QueryDSL でバッチ更新API を使いたい場合には BatchParamsBuilder の利用も検討してみてください。

Discussion