Home About
Kotlin , Spring Boot

kotlinx datetime と SpringBoot , 2つの期間の重なりを判定

Kotlin/JS 経由で JavaScript から DateTime を扱う場合 java.time.* を使うことができない。 その代わり kotlinx-datetime を使えばよいとのことでこれを試す。

例として、2つの期間 a, b を比較して、その状況を返すライブラリを考えることにする。 たとえば...

本当は、2つの期間のとりうる関係の全てのケースを考慮した判定器を考えたいのだが、 話が込み入りすぎるので、ここでは2つの期間に重なりが あるか or ないか の 2つのケースだけを判定する isOverlapped 関数を実装することにする。

SpringBoot

まず SpringBoot を使うので spring initializr にアクセスして以下の設定でプロジェクトの雛形を生成。

my.datetime project settings

build.gradle の dependencies に datetime を追加:

implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.4.0'

Period と isOverlapped 関数

src/main/kotlin/my/datetime/Period.kt を用意:

package my.datetime

data class Period(val start: String, val stop: String)

Period は期間を表すデータクラスです。

Period("2022-12-01", "2022-12-31")

のように開始日と終了日を指定して期間を表現します。

ここでは、2つの指定日が start < stop の関係にあることを暗黙の前提としています。 逆に指定することを制限する機能は一切ないので、実際のプロジェクトでは、この部分の妥当性をチェックしましょう。

続いて、2つの期間の重なり状況を調べる関数 isOverlapped を定義:

// Period.kt

fun isOverlapped(a: Period, b: Period): Boolean {

    val toLocalDateTime: (String)->LocalDateTime = {
        val v = LocalDate.parse(it)
        val h = 0
        val m = 0
        LocalDateTime(v.year, v.month, v.dayOfMonth, h, m)
    }

    // (t0 < t1) かを検査:
    val isBefore: (LocalDateTime, LocalDateTime)->Boolean = { t0,t1->
        (t0.compareTo(t1) < 0)
    }

    val isNotOverlapped: (LocalDateTime, LocalDateTime, LocalDateTime, LocalDateTime)->Boolean = { aStart, aStop, bStart, bStop->
        //
        // 重なりがないケースを考える:
        //

        // case 0)
        // aStop が bStart より古い場合
        // ---------------------------
        // Period a: <--->
        // Period b:       <--->
        // ---------------------------
        val notOverlapped0 = isBefore(aStop, bStart)

        // case 1)
        // bStop が aStart より古い場合
        // ---------------------------
        // Period a:        <--->
        // Period b:  <--->
        // ---------------------------
        val notOverlapped1 = isBefore(bStop, aStart)

        // case 0 or 1 が true の場合
        (notOverlapped0 || notOverlapped1)
    }

    return !isNotOverlapped(
        toLocalDateTime(a.start),
        toLocalDateTime(a.stop),
        toLocalDateTime(b.start),
        toLocalDateTime(b.stop))
}

isOverlapped 関数の中で3つの関数リテラルを定義しています。

Period は年月日までしか保持していない(を前提としている)ので、 これを LocalDateTime として比較する場合に、 時間と分はそれぞれ 0 にしています。 つまり、Period.start が 2022-12-01 とすると この日の0時0分を この値の LocalDateTime とする、ということです。

isBefore 関数で 2つの LocalDateTime を比較しています。

2つの期間が重なっていない条件は isNotOverlapped 関数内の case 0) or case 1) になります。

テストを書く

それでは次にテストを書いてこの isOverlapped 関数が意図通り機能するか確認していきます。

src/test/kotlin/my/datetime/PeriodTests.kt を用意します。

package my.datetime

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions
import org.springframework.boot.test.context.SpringBootTest

@SpringBootTest
class PeriodTests {
    @Test
    fun test1() {
        val a = Period("2022-01-01", "2022-12-31")
        val b = Period("2021-12-01", "2021-12-31")
        val result = isOverlapped(a,b)
        Assertions.assertEquals(false, result)
    }
}

test1 ではこのエントリーの冒頭で出したケースを表しています。 期間a が 2022-01-01..2022-12-31 で、期間b が 2021-12-01..2021-12-31 、そして isOverlapped で判定して Assertions.assertEquals で確かめます。

テストの実行はプロジェクトルートで test タスクをを実行します。

./gradlew test

冒頭のもう一つのケースについてもテストを書いて確かめましょう。

    @Test
    fun test2() {
        val a = Period("2022-01-01", "2022-12-31")
        val b = Period("2022-01-10", "2022-01-31")
        val result = isOverlapped(a,b)
        Assertions.assertEquals(true, result)
    }

テストを実行して意図通りになることを確認しましょう。

まとめ

そもそもの動機として、Kotlin/JS でここで書いた isOverlapped 関数を使いたい。 だから、これ Period.kt を js に変換して...という作業がこのあと続くのだが、そこまで書く体力がなくなったので今日はここまで。

追伸

なお、これ build.gradle に kotlinx-datetime の依存を追加しただけなので、 別に Spring Boot ではなく kotlin script でも試せないの?と思ったのですが、うまくいきませんでした。

こんなスクリプトファイル一枚で試せればいいのに:

// datetime.main.kts

@file:Repository("https://repo1.maven.org/maven2/")
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")

import kotlinx.datetime.*

data class Period(val start: String, val stop: String)

fun isOverlapped(a: Period, b: Period): Boolean {

    val toLocalDateTime: (String)->LocalDateTime = {
        val v = LocalDate.parse(it)
        val h = 0
        val m = 0
        LocalDateTime(v.year, v.month, v.dayOfMonth, h, m)
    }

    // (t0 < t1) かを検査:
    val isBefore: (LocalDateTime, LocalDateTime)-> Boolean = { t0,t1->
        (t0.compareTo(t1) < 0)
    }

    val isNotOverlapped: (LocalDateTime, LocalDateTime, LocalDateTime, LocalDateTime)->Boolean = { aStart, aStop, bStart, bStop->
        //
        // 重なりがないケースを考える:
        //

        // case 0)
        // aStop が bStart より古い場合
        // ---------------------------
        // Period a: <--->
        // Period b:       <--->
        // ---------------------------
        val notOverlapped0 = isBefore(aStop, bStart)

        // case 1)
        // bStop が aStart より古い場合
        // ---------------------------
        // Period a:        <--->
        // Period b:  <--->
        // ---------------------------
        val notOverlapped1 = isBefore(bStop, aStart)

        // case 0 or 1 が true の場合
        (notOverlapped0 || notOverlapped1)
    }

    return !isNotOverlapped(
        toLocalDateTime(a.start),
        toLocalDateTime(a.stop),
        toLocalDateTime(b.start),
        toLocalDateTime(b.stop))
}


val test1: ()->Unit = {
    val a = Period("2022-01-01", "2022-12-31")
    val b = Period("2021-12-01", "2021-12-31")
    val result = isOverlapped(a,b)
    println("- test1 assert false : ${result}")
}

val test2: ()->Unit = {
    val a = Period("2022-01-01", "2022-12-31")
    val b = Period("2022-01-10", "2022-01-31")
    val result = isOverlapped(a,b)
    println("- test1 assert true : ${result}")
}


test1()
test2()

たぶん、それだったら普通に java.time.* 使えってことなんだね。 以下のように:

// datetime.main.kts

import java.time.*

data class Period(val start: String, val stop: String)

fun isOverlapped(a: Period, b: Period): Boolean {

    val toLocalDateTime: (String)->LocalDateTime = {
        val v = LocalDate.parse(it)
        val h = 0
        val m = 0
        //LocalDateTime(v.year, v.month, v.dayOfMonth, h, m)
        LocalDateTime.of(v.year, v.month, v.dayOfMonth, h, m)
    }

    // (t0 < t1) かを検査:
    val isBefore: (LocalDateTime, LocalDateTime)-> Boolean = { t0,t1->
        (t0.compareTo(t1) < 0)
    }

    val isNotOverlapped: (LocalDateTime, LocalDateTime, LocalDateTime, LocalDateTime)->Boolean = { aStart, aStop, bStart, bStop->
        //
        // 重なりがないケースを考える:
        //

        // case 0)
        // aStop が bStart より古い場合
        // ---------------------------
        // Period a: <--->
        // Period b:       <--->
        // ---------------------------
        val notOverlapped0 = isBefore(aStop, bStart)

        // case 1)
        // bStop が aStart より古い場合
        // ---------------------------
        // Period a:        <--->
        // Period b:  <--->
        // ---------------------------
        val notOverlapped1 = isBefore(bStop, aStart)

        // case 0 or 1 が true の場合
        (notOverlapped0 || notOverlapped1)
    }

    return !isNotOverlapped(
        toLocalDateTime(a.start),
        toLocalDateTime(a.stop),
        toLocalDateTime(b.start),
        toLocalDateTime(b.stop))
}


val test1: ()->Unit = {
    val a = Period("2022-01-01", "2022-12-31")
    val b = Period("2021-12-01", "2021-12-31")
    val result = isOverlapped(a,b)
    println("- test1 assert false : ${result}")
}

val test2: ()->Unit = {
    val a = Period("2022-01-01", "2022-12-31")
    val b = Period("2022-01-10", "2022-01-31")
    val result = isOverlapped(a,b)
    println("- test1 assert true : ${result}")
}


test1()
test2()

LocalDateTime インスタンスを作り出す部分を以下のように書き換える必要がありました。

    //LocalDateTime(v.year, v.month, v.dayOfMonth, h, m)
    LocalDateTime.of(v.year, v.month, v.dayOfMonth, h, m)

これだけの変更で kotlin script として作動するようになりました。

$ kotlinc -script datetime.main.kts
- test1 assert false : false
- test1 assert true : true

以上です。