LoginSignup
0
0

More than 1 year has passed since last update.

【jOOQ】PostgreSQL向けに一括Upsertする共通処理を作りたかった【Kotlin】

Last updated at Posted at 2022-06-18

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))

使い方

IDFULL_NAMEYOMIの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でこれをするためには、以下のような処理が必要でした。

ベタ書きでexcludedを設定する例
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に渡しても問題ありません。

  1. 執筆時点で3.18マイルストーンに含まれていたためこのように記述していますが、2017年から解決されていないissueであるため、可能性は低いかもしれません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0