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

`jOOQ`で一括`Upsert`する方法がググってもあまりヒットしなかったため書きます。

# 注意書き
まず、筆者は`jOOQ`に不慣れです。

また、このコードは業務用コードに入れるか検討して最終的にボツにしたものです。
軽い動作確認はしていますが、全パターンで動くかまでは確認していません（例として、`PostgreSQL`の一括`insert`数上限を考慮する必要がありそうな気がしています）。

更に、調べた限りでは、データベース側で生成した値を用いない場合は`batchMerge`を用いるのが良さそうでした。

https://www.jooq.org/javadoc/3.15.x/org.jooq/org/jooq/DSLContext.html#batchMerge(org.jooq.UpdatableRecord...)

「データベース側で生成した値を用いない場合」という縛りも、`3.18`以降[^jv]で将来的に無くなる可能性が有ります。

https://github.com/jOOQ/jOOQ/issues/3327

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

# コード
作成した共通処理は以下の通りです。
インターフェースとしては、「値を指定されたテーブルに一括`Upsert`し、結果を`Record`に格納して返却する」形になっています。

```kotlin
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`する例です。

```kotlin
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`側は以下を参考にさせて頂きました。

https://thinkit.co.jp/article/9586#:~:text=(4)%E4%BB%A3%E6%9B%BF%E5%87%A6%E7%90%86%EF%BC%88DO%20UPDATE%E5%8F%A5%EF%BC%89%E3%81%AEEXCLUDED%E5%8F%A5

`jOOQ`でこれをするためには、以下のような処理が必要でした。

```kotlin:ベタ書きで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`です。

この部分は以下を参考にさせて頂きました。

https://github.com/jOOQ/jOOQ/issues/7912

### Args/ArgsImpl関連
`Args`及び`ArgsImpl`は、登録に用いる引数を安全に管理するための仕組みです。
これを導入した理由は、`jOOQ`で生成される`Record`を引数に使った場合、`columns`に対応する適切な値が設定されているか確認できないことです。

この辺りを積極的に安全化するモチベーションが無ければ、`jOOQ`で生成される`Record`をそのまま`values`に渡しても問題ありません。