KMongo でデータクラスを setOnInsert する

KMongo でデータクラスをそのまま setOnInsert する方法を考えてみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20191217/

はじめに

MongoDB は {upsert: true}updatefindAndModify する際、指定の条件に合致するドキュメントが存在する場合は何も行わず、存在しない場合に登録するドキュメントを $setOnInsert で指定できるようになっています。

KMongo にも setOnInsert メソッドは用意されていますが、現時点では以下のような実装になっており、データクラスをそのままセットできるようになっていません。(項目名と値を指定する事になる)

kmongo-property/src/main/kotlin/org/litote/kmongo/Updates.kt
fun <@OnlyInputTypes T> setOnInsert(property: KProperty<T?>, value: T): Bson =
    Updates.setOnInsert(property.path(), value)

そのため、データクラスをそのまま setOnInsert するには何らかの工夫が必要だと思われます。

(a) リフレクション利用

下記のデータクラスを使って、targetId と revision が同じドキュメントは複数登録しないような処理を実装してみます。

データクラス例
typealias EventDate = OffsetDateTime

data class Event(val targetId: String, val revision: Long,
                 val date: EventDate = EventDate.now())

とりあえず、データクラスの項目を 1つずつ指定して combine する方法が考えられます。

コード例1
・・・
val col = client.getDatabase(dbName).getCollection<Event>()

val eventData = Event(・・・)

col.findOneAndUpdate(
    and(
        Event::targetId eq eventData.targetId,
        Event::revision eq eventData.revision
    ),
    combine(
        setOnInsert(Event::targetId, eventData.targetId),
        setOnInsert(Event::revision, eventData.revision),
        setOnInsert(Event::date, eventData.date)
    ),
    findOneAndUpdateUpsert()
)

リフレクションを使うと以下のようになります。

コード例2
・・・
val col = client.getDatabase(dbName).getCollection<Event>()

val eventData = Event(・・・)

col.findOneAndUpdate(
    and(
        Event::targetId eq eventData.targetId,
        Event::revision eq eventData.revision
    ),
    combine(
        Event::class.memberProperties.map {
            setOnInsert(it, it.get(eventData))
        }
    ),
    findOneAndUpdateUpsert()
)

サンプルアプリケーション(sample1)

下記サンプルアプリケーションで動作確認してみます。

なお、findOneAndUpdate の第三引数を findOneAndUpdateUpsert() もしくは findOneAndUpdateUpsert().returnDocument(ReturnDocument.BEFORE) とすると処理前のドキュメントの内容が返り ※、findOneAndUpdateUpsert().returnDocument(ReturnDocument.AFTER) とすると処理後の内容が返ってきます。

 ※ 該当ドキュメントが存在しない場合(つまり、新規登録した場合)は
    null が返ってきます
sample1/src/main/kotlin/App.kt
import com.mongodb.ConnectionString
import com.mongodb.client.model.ReturnDocument
import org.litote.kmongo.*
import java.time.OffsetDateTime
import kotlin.reflect.full.memberProperties

typealias EventDate = OffsetDateTime

data class Event(val targetId: String, val revision: Long,
                 val date: EventDate = EventDate.now())

fun main() {
    val conStr = "mongodb://localhost"
    val dbName = "sample"

    KMongo.createClient(ConnectionString(conStr)).use { client ->
        val col = client.getDatabase(dbName).getCollection<Event>()

        val d1 = Event("a1", 1)

        val res = col.findOneAndUpdate(
            and(
                Event::targetId eq d1.targetId,
                Event::revision eq d1.revision
            ),
            combine(
                setOnInsert(Event::targetId, d1.targetId),
                setOnInsert(Event::revision, d1.revision),
                setOnInsert(Event::date, d1.date)
            ),
            findOneAndUpdateUpsert()
        )

        println(res)

        val d2 = Event("b1", 1)

        val res2 = col.findOneAndUpdate(
            and(
                Event::targetId eq d2.targetId,
                Event::revision eq d2.revision
            ),
            combine(
                Event::class.memberProperties.map {
                    setOnInsert(it, it.get(d2))
                }
            ),
            findOneAndUpdateUpsert().returnDocument(ReturnDocument.AFTER)
        )

        println(res2)
    }
}

動作確認

Gradle でビルド・実行します。

実行(1回目)
> gradle sample1:run

・・・
null
Event(targetId=b1, revision=1, date=2019-12-15T13:03:51.586Z)
・・・

MongoDB のドキュメント内容は以下の通りです。

MongoDB データ確認(1回目)
> mongo mongodb://localhost/sample

・・・
> db.event.find()

{ "_id" : ObjectId("5df62f37a4d2dfcdb84e6f5e"), "revision" : NumberLong(1), "targetId" : "a1", "date" : ISODate("2019-12-15T13:03:50.206Z") }
{ "_id" : ObjectId("5df62f37a4d2dfcdb84e6f60"), "revision" : NumberLong(1), "targetId" : "b1", "date" : ISODate("2019-12-15T13:03:51.586Z") }

再度実行してみます。

実行(2回目)
> gradle sample1:run

・・・
Event(targetId=a1, revision=1, date=2019-12-15T13:03:50.206Z)
Event(targetId=b1, revision=1, date=2019-12-15T13:03:51.586Z)
・・・

既に該当ドキュメントが登録済みのため、MongoDB のドキュメントに変化はありません。

MongoDB データ確認(2回目)
> db.event.find()

{ "_id" : ObjectId("5df62f37a4d2dfcdb84e6f5e"), "revision" : NumberLong(1), "targetId" : "a1", "date" : ISODate("2019-12-15T13:03:50.206Z") }
{ "_id" : ObjectId("5df62f37a4d2dfcdb84e6f60"), "revision" : NumberLong(1), "targetId" : "b1", "date" : ISODate("2019-12-15T13:03:51.586Z") }

(b) JSON 利用

(a) の方法には問題があり、下記のようなデータクラスで不都合が生じます。

@JsonTypeInfo 部分が処理されないため、復元先のクラス名を設定する @class 項目が作られず、EventDetail 部分が実装クラス(CreatedUpdated)へ復元できずにエラーとなります。

データクラス例
interface EventDetail

data class Created(val value: Int) : EventDetail
data class Updated(val oldValue: Int, val newValue: Int) : EventDetail

data class Event(val targetId: String, val revision: Long,
                 @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) val detail: EventDetail,
                 val date: EventDate = EventDate.now())

そこで、ここでは .json でデータクラスの JSON 文字列を取得し、文字列として {$setOnInsert: {"targetId": "・・・", ・・・}} を組み立て BSON 化する方法を試します。

コード例
・・・
val col = client.getDatabase(dbName).getCollection<Event>()

val eventData = Event(・・・)

col.findOneAndUpdate(
    and(
        Event::targetId eq eventData.targetId,
        Event::revision eq eventData.revision
    ),
    // 文字列として $setOnInsert の内容を作って BSON 化
    "{${MongoOperator.setOnInsert}: ${eventData.json}}".bson,
    findOneAndUpdateUpsert()
)

サンプルアプリケーション(sample2)

下記サンプルアプリケーションで動作確認してみます。

sample2/src/main/kotlin/App.kt
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.mongodb.ConnectionString
import com.mongodb.client.model.ReturnDocument
import org.litote.kmongo.*
import java.time.OffsetDateTime

typealias EventDate = OffsetDateTime

interface EventDetail

data class Created(val value: Int) : EventDetail
data class Updated(val oldValue: Int, val newValue: Int) : EventDetail

data class Event(val targetId: String, val revision: Long,
                 @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) val detail: EventDetail,
                 val date: EventDate = EventDate.now())

fun main() {
    val conStr = "mongodb://localhost"
    val dbName = "sample2"

    KMongo.createClient(ConnectionString(conStr)).use { client ->
        val col = client.getDatabase(dbName).getCollection<Event>()

        val d1 = Event("a1", 1, Created(1))

        val res1 = col.findOneAndUpdate(
            and(
                Event::targetId eq d1.targetId,
                Event::revision eq d1.revision
            ),
            "{${MongoOperator.setOnInsert}: ${d1.json}}".bson,
            findOneAndUpdateUpsert().returnDocument(ReturnDocument.AFTER)
        )

        println(res1)

        val d2 = Event("a1", 2, Updated(1, 2))

        val res2 = col.findOneAndUpdate(
            and(
                Event::targetId eq d2.targetId,
                Event::revision eq d2.revision
            ),
            "{${MongoOperator.setOnInsert}: ${d2.json}}".bson,
            findOneAndUpdateUpsert().returnDocument(ReturnDocument.AFTER)
        )

        println(res2)
    }
}

動作確認

実行します。

実行(1回目)
> gradle sample2:run

・・・
Event(targetId=a1, revision=1, detail=Created(value=1), date=2019-12-15T16:35:52.351Z)
Event(targetId=a1, revision=2, detail=Updated(oldValue=1, newValue=2), date=2019-12-15T16:35:53.265Z)
・・・

MongoDB のドキュメント内容は以下の通り。 @class 項目にクラス名が保存されています。

MongoDB データ確認(1回目)
> mongo mongodb://localhost/sample2

・・・
> db.event.find()

{ "_id" : ObjectId("5df660e9a4d2dfcdb84e7206"), "revision" : 1, "targetId" : "a1", "date" : ISODate("2019-12-15T16:35:52.351Z"), "detail" : { "@class" : "Created", "value" : 1 } }
{ "_id" : ObjectId("5df660e9a4d2dfcdb84e7208"), "revision" : 2, "targetId" : "a1", "date" : ISODate("2019-12-15T16:35:53.265Z"), "detail" : { "@class" : "Updated", "oldValue" : 1, "newValue" : 2 } }

再度実行してみます。

実行(2回目)
> gradle sample2:run

・・・
Event(targetId=a1, revision=1, detail=Created(value=1), date=2019-12-15T16:35:52.351Z)
Event(targetId=a1, revision=2, detail=Updated(oldValue=1, newValue=2), date=2019-12-15T16:35:53.265Z)
・・・

ドキュメントが登録済みのため MongoDB の内容に変化はありません。

MongoDB データ確認(2回目)
> db.event.find()

{ "_id" : ObjectId("5df660e9a4d2dfcdb84e7206"), "revision" : 1, "targetId" : "a1", "date" : ISODate("2019-12-15T16:35:52.351Z"), "detail" : { "@class" : "Created", "value" : 1 } }
{ "_id" : ObjectId("5df660e9a4d2dfcdb84e7208"), "revision" : 2, "targetId" : "a1", "date" : ISODate("2019-12-15T16:35:53.265Z"), "detail" : { "@class" : "Updated", "oldValue" : 1, "newValue" : 2 } }

備考

今回使用した Gradle 用のビルド定義ファイルは以下の通りです。

build.gradle
buildscript {
    repositories {
        jcenter()
    }

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

plugins {
    id 'org.jetbrains.kotlin.jvm' version "$kotlin_version" apply false
}

allprojects {
    apply plugin: 'org.jetbrains.kotlin.jvm'
    apply plugin: 'application'

    mainClassName = 'AppKt'

    repositories {
        mavenLocal()
        jcenter()
    }

    dependencies {
        implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
        implementation "org.litote.kmongo:kmongo:$kmongo_version"
    }
}
gradle.properties
kotlin_version=1.3.61
kmongo_version=3.11.2
settings.gradle
rootProject.name = 'kmongo_setoninsert'
include 'sample1', 'sample2'