jOOQ
で一括Upsert
する方法がググってもあまりヒットしなかったため書きます。
注意書き
まず、筆者はjOOQ
に不慣れです。
また、このコードは業務用コードに入れるか検討して最終的にボツにしたものです。
軽い動作確認はしていますが、全パターンで動くかまでは確認していません(例として、PostgreSQL
の一括insert
数上限を考慮する必要がありそうな気がしています)。
更に、調べた限りでは、データベース側で生成した値を用いない場合はbatchMerge
を用いるのが良さそうでした。
「データベース側で生成した値を用いない場合」という縛りも、3.18
以降1で将来的に無くなる可能性が有ります。
コード
作成した共通処理は以下の通りです。
インターフェースとしては、「値を指定されたテーブルに一括Upsert
し、結果をRecord
に格納して返却する」形になっています。
import org.jooq.DSLContext
import org.jooq.Field
import org.jooq.InsertOnDuplicateSetMoreStep
import org.jooq.InsertOnDuplicateSetStep
import org.jooq.Record
import org.jooq.Table
import org.jooq.TableField
/**
* @property table: 更新対象テーブル
*/
class BatchUpsertParams<R : Record> private constructor(
val table: Table<R>,
private val columns: List<TableField<R, *>>,
private val values: List<List<*>>
) {
private val excludedTable: Table<R> = table.`as`("excluded")
// excluded句の設定、foldの型の整合性の問題で最後にダウンキャストしている(columnsは1件以上なため、これで成功する)
private fun setExcluded(query: InsertOnDuplicateSetStep<R>): InsertOnDuplicateSetMoreStep<R> =
columns.fold(query) { acc, column ->
acc.setExclude(excludedTable, column)
} as InsertOnDuplicateSetMoreStep<R>
// クエリ生成処理
private fun generateQuery(create: DSLContext): InsertOnDuplicateSetMoreStep<R> = create
.insertInto(table)
.columns(columns)
.let { values.fold(it) { query, value -> query.values(value) } } // 値のセット
.onDuplicateKeyUpdate()
.let { setExcluded(it) }
/**
* valuesに設定する値を格納するクラス
* BatchUpsertParamsに設定したcolumns全てについて値をputする必要がある
*/
sealed interface Args<R : Record> {
fun <V> put(column: TableField<R, V>, value: V): Args<R>
}
// Argsの実装、余計な関数・プロパティを公開しないようインターフェースで分離している
private class ArgsImpl<R : Record>(private val columns: List<TableField<R, *>>) : Args<R> {
// 初期化されていないことを表す値
companion object { private val ABSENT_VALUE = Any() }
// valuesに設定する値はcolumnsの順番に並べて管理する
private val args: Array<Any?> = Array(columns.size) { ABSENT_VALUE }
private val indexMap = columns.mapIndexed { idx, it -> it to idx }.toMap()
override fun <V> put(column: TableField<R, V>, value: V): Args<R> {
args[indexMap.getValue(column)] = value
return this
}
// 初期化後、全ての値がputされていなければエラー
fun asList() = args.apply {
this.forEachIndexed { idx, it ->
if (it === ABSENT_VALUE) throw IllegalStateException("${columns[idx].name}が初期化されていません。")
}
}.asList()
}
companion object {
/**
* @param columns: 更新対象もしくはprimary/unique key
* @param values: 更新に用いる値
* @param binder: [values]を[columns]にbindする処理
*/
fun <R : Record, V> initialize(
columns: List<TableField<R, *>>,
values: List<V>,
binder: (Args<R>, V) -> Args<R>
): BatchUpsertParams<R> {
val table = columns.firstOrNull()
?.let { it.table!! }
?: throw IllegalArgumentException("columnsは1件以上指定してください。")
return BatchUpsertParams(
table,
columns,
values.map { (binder(ArgsImpl(columns), it) as ArgsImpl<R>).asList() }
)
}
// generateQuery関数をprivateにしたかった都合でこちらに定義
fun <R : Record> DSLContext.upsertAll(params: BatchUpsertParams<R>): List<R> = params.generateQuery(this)
.returning()
.fetch()
}
}
// genericsの都合で外出し
private fun <R : Record, T> InsertOnDuplicateSetStep<R>.setExclude(
excludedTable: Table<R>,
column: Field<T>
): InsertOnDuplicateSetMoreStep<R> = this.set(column, excludedTable.field(column.name, column.type))
使い方
ID
、FULL_NAME
、YOMI
の3カラムを持つテーブルに対してUpsert
する例です。
val columns: List<TableField<TableRecord, *>> = listOf(
TABLE.ID,
TABLE.FULL_NAME,
TABLE.YOMI
)
val params = BatchUpsertParams.initialize(
columns = columns,
// r1, r2はUpsertしたい値を想定、apply内は編集処理想定
values = listOf(
r1.apply { fullName = "edit" },
r2.apply { yomi = "edit" }
)
) { args, record ->
args
.put(TABLE.ID, record.id)
.put(TABLE.FULL_NAME, record.fullName)
.put(TABLE.YOMI, record.yomi)
}
val create: DSLContext = /* 略 */
val result = create.upsertAll(params)
解説
Upsertする仕組み
PostgreSQL
側は以下を参考にさせて頂きました。
jOOQ
でこれをするためには、以下のような処理が必要でした。
private val excludedTable = TABLE.`as`("excluded")
create.insertInto(TABLE)
.columns(TABLE.ID, TABLE.FULL_NAME, /* ... */)
.values(/* ... */)
/* ... */
.values(/* ... */)
.onDuplicateKeyUpdate()
.set(TABLE.ID, excludedTable.ID)
.set(TABLE.FULL_NAME, excludedTable.FULL_NAME)
/* ... */
この辺りをループ処理で置き換えたのがサンプルコードのBatchUpsertParams::setExcluded
です。
この部分は以下を参考にさせて頂きました。
Args/ArgsImpl関連
Args
及びArgsImpl
は、登録に用いる引数を安全に管理するための仕組みです。
これを導入した理由は、jOOQ
で生成されるRecord
を引数に使った場合、columns
に対応する適切な値が設定されているか確認できないことです。
この辺りを積極的に安全化するモチベーションが無ければ、jOOQ
で生成されるRecord
をそのままvalues
に渡しても問題ありません。
-
執筆時点で
3.18
マイルストーンに含まれていたためこのように記述していますが、2017年から解決されていないissue
であるため、可能性は低いかもしれません。 ↩