Kotlin + JOOQ + Spring + Flyway + Testcontainersで書くDB処理

この記事は筆者の[ソロ Advent Calendar 2022](https://qiita.com/advent-calendar/2022/panda) 7日目の記事です。

前回まではSpockについての記事を書いてきましたが今回はORMであるJOOQをKotlinで使用する方法について紹介していきます。また、マイグレーションにFlyway, テストにTestcontainersを使用します。

本記事で作成したサンプルコードはこちら

https://github.com/JY8752/jooq-demo

# JOOQとは
JVM言語においてORMの選択肢は多くあり、JOOQもそのうちの一つである。JOOQを使用するメリットとして以下のような特徴がある。
- SQLライクに処理がかける。
- 複雑なSQLが書きやすい。
- 型安全。
- DBからコードを自動生成できる。

https://www.jooq.org/

# Spring
Spring Initializerでプロジェクトを作成する。JOOQは依存関係が用意されているためJOOQとMySQLドライバーの依存関係を選択しておく。今回はTestcontainersも使用するため依存関係に追加しておく。build.gradle.ktsは以下のようになる。

```kotlin:build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.7.5"
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
    kotlin("jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17

repositories {
    mavenCentral()
}

extra["testcontainersVersion"] = "1.17.4"

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-jooq")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    runtimeOnly("com.mysql:mysql-connector-j")
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // testcontainers
    testImplementation("org.testcontainers:junit-jupiter")
    testImplementation("org.testcontainers:mysql")
}

dependencyManagement {
    imports {
        mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}")
    }
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "17"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}
```

FlywayとJOOQを組み合わせて使う場合、FlywayのマイグレーションとJOOQのコード生成タスクがうまく噛み合わないのでFlywayは手動でマイグレーションを実行する。また、JOOQのコード生成用のタスク定義も行う。テストにKotestを追加して、最終的なbuild.gradle.ktsは以下のようになる。

```diff_kotlin:build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.7.5"
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
+    id("nu.studer.jooq") version "8.0" // これを追加
+    id("org.flywaydb.flyway") version "8.0.1" //これも追加
    kotlin("jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17

repositories {
    mavenCentral()
}

extra["testcontainersVersion"] = "1.17.4"

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-jooq")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    runtimeOnly("com.mysql:mysql-connector-j")
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // testcontainers
    testImplementation("org.testcontainers:junit-jupiter")
    testImplementation("org.testcontainers:mysql")

    // flyway
+    implementation("org.flywaydb:flyway-mysql")

    // kotest
+    val kotest_version = "5.5.4"
+    testImplementation("io.kotest:kotest-runner-junit5-jvm:$kotest_version")
+    testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.2")

    // jooq
+    jooqGenerator("com.mysql:mysql-connector-j")
+    jooqGenerator("jakarta.xml.bind:jakarta.xml.bind-api:4.0.0")
}

dependencyManagement {
    imports {
        mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}")
    }
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "17"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

+ jooq {
+    configurations {
+        create("main") {
+            jooqConfiguration.apply {
+                jdbc.apply {
+                    url = "jdbc:mysql://localhost:3306/library?enabledTLSProtocols=TLSv1.2"
+                    user = "root"
+                    password = "root"
+                }
+                generator.apply {
+                    name = "org.jooq.codegen.KotlinGenerator"
+                    database.apply {
+                        name = "org.jooq.meta.mysql.MySQLDatabase"
+                        inputSchema = "library"
+                        excludes = "flyway_schema_history"
+                    }
+                    generate.apply {
+                        isDeprecated = false
+                        isTables = true
+                    }
+                    target.apply {
+                        packageName = "com.example.ktknowledgeTodo.infra.jooq"
+                        directory = "$buildDir/generated/source/jooq/main"
+                    }
+                }
+            }
+        }
+    }
+ }

+ flyway {
+    url = "jdbc:mysql://localhost:3306/library?enabledTLSProtocols=TLSv1.2"
+    user = "root"
+    password = "root"
+ }
```
手抜きで　DB情報を直書きしていますが、実際には環境変数などに置き換えてください。JOOQタスクの設定は以下の記事をほぼそのまま使用させていただいてます🙇‍♂️

https://zenn.dev/yamachoo/articles/spring-jooq-flyway-mysql

# DB
DBはdockerで準備します。

```yml:docker-compose.yml
version: '3'

services:
  # MySQL
  db:
    image: mysql:latest
    container_name: mysql_container
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: library
      TZ: 'Asia/Tokyo'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    volumes:
      - ./docker/db/data:/var/lib/mysql
      - ./docker/db/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./docker/db/sql:/docker-entrypoint-initdb.d
    ports:
      - "3306:3306"
```

マイグレーション用のSQLファイルは以下。

```sql:V1.0.0__create_table.sql
CREATE DATABASE IF NOT EXISTS `library`;

USE `library`;

CREATE TABLE IF NOT EXISTS `author` (
  `id` int NOT NULL AUTO_INCREMENT,
  `first_name` varchar(255) DEFAULT NULL,
  `last_name` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
);
```

dokcerを起動
```
docker-comose up -d
```

マイグレーション
```
./gradlew flywayMigrate
```

JOOQコード生成
```
./gradlew generateJooq
```

build配下にコードが生成されていれば完了です。

# 実装
以下のようなAutherテーブルを扱うインターフェースなどを実装していく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551753/b84e4871-8e54-933c-1823-4360c7297946.png)

インターフェイスの定義
```kotlin:AuthorRepository
package com.example.jooqdemo.domain.repository

import com.example.jooqdemo.domain.model.Author

interface AuthorRepository {
    fun findById(id: Int): Author?
    fun findAll(): List<Author>
    fun save(firstName: String, lastName: String): Author
    fun deleteAll()
}
```

インターフェイスの実装クラス。特筆することも特にないがDSLContextをDIしてSQLみたいに処理を組み立てる。本当にSQLのように書ける。最終的な戻り値がRecordクラスになるのでModelクラスを用意
して変換している。

```kotlin:AuthoerRepositoryImpl
package com.example.jooqdemo.infrastructure.repository

import com.example.jooqdemo.domain.model.Author
import com.example.jooqdemo.domain.repository.AuthorRepository
import com.example.ktknowledgeTodo.infra.jooq.tables.Author.Companion.AUTHOR
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository

@Repository
class AuthorRepositoryImpl(
    private val dslContext: DSLContext
) : AuthorRepository {
    override fun findById(id: Int): Author? {
        return this.dslContext.select()
            .from(AUTHOR)
            .where(AUTHOR.ID.eq(id))
            .fetchOne()?.let { toModel(it) }
    }

    override fun findAll(): List<Author> {
        return this.dslContext.select()
            .from(AUTHOR)
            .fetch().map { toModel(it) }
    }

    override fun save(firstName: String, lastName: String): Author {
        val record = this.dslContext.newRecord(AUTHOR).also {
            it.firstName = firstName
            it.lastName = lastName
            it.store()
        }
        return Author(record.id!!, record.firstName!!, record.lastName!!)
    }

    override fun deleteAll() {
        this.dslContext.deleteFrom(AUTHOR).execute()
    }

    private fun toModel(record: Record) = Author(
        record.getValue(AUTHOR.ID)!!,
        record.getValue(AUTHOR.FIRST_NAME)!!,
        record.getValue(AUTHOR.LAST_NAME)!!
    )
}

```

```kotlin:Authoer
package com.example.jooqdemo.domain.model

data class Author(val id: Int, val firstName: String, val lastName: String)
```

# テスト
テストにはKotestとTestcontainersを使用します。まず、準備としてKotestで@SpringBootTestを使用するのにExtentionを登録する必要があるので以下のようなConfigクラスを用意する。

```kotlin:ProjectConfig
package com.example.jooqdemo

import io.kotest.core.config.AbstractProjectConfig
import io.kotest.extensions.spring.SpringExtension

class ProjectConfig : AbstractProjectConfig() {
    override fun extensions() = listOf(SpringExtension)
}
```

Testcontainersを使用する場合、大量のログが出るのが嫌なので以下のlogbackファイルを配置して止めておく。

```xml: logback-test.xml
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="info">
        <appender-ref ref="STDOUT"/>
    </root>

    <logger name="org.testcontainers" level="INFO"/>
    <logger name="com.github.dockerjava" level="WARN"/>
    <logger name="com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.wire" level="OFF"/>
</configuration>
```

実際のテストはこんな感じ

```kotlin:AuthorRepositoryTest
package com.example.jooqdemo.domain

import com.example.jooqdemo.domain.repository.AuthorRepository
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.flywaydb.core.Flyway
import org.springframework.boot.test.context.SpringBootTest
import org.testcontainers.containers.MySQLContainer

@SpringBootTest
internal class AuthorRepositoryTest(
    private val authorRepository: AuthorRepository
) : FunSpec({
    beforeSpec {
        //起動したMYSQLコンテナでマイグレーションを実行する
        Flyway.configure()
            .dataSource(container.jdbcUrl, container.username, container.password)
            .load()
            .migrate()
    }
    //テストが終わるたびに後処理
    afterTest { authorRepository.deleteAll() }
    test("save and find") {
        val saved = authorRepository.save("first", "last")
        val find = authorRepository.findById(saved.id)
        saved shouldBe find
    }
    test("find count one") {
        authorRepository.save("first", "last")
        authorRepository.findAll().size shouldBe 1
    }
}) {
    companion object {
        //MySQLコンテナを起動
        val container = MySQLContainer("mysql:latest").apply {
            withDatabaseName("library")
            withUsername("root")
            withPassword("root")
            start()
        }
    }
}
```

これでテスト前にMySQLコンテナの起動とマイグレーション実行をした上で好きにテストが書けるようになります。注意点として起動したコンテナはテスト間で共有して使用されるため作成したデータの後処理をしないと他のテストに影響が出てしまいます。afterTestで作成したデータを削除していますが、この処理がないと2つ目のテストは失敗することになります。

# まとめ
今回は以下の内容を紹介しました。
- Kotlin + SpringプロジェクトにJOOQを導入する手順
- JOOQと合わせてFlywayを導入する手順
- Kotest, Testcontainersを使用したDB処理のテストについて


触ってみた感想としてはかなり書き心地はよかったです。筆者はHibernateの経験が多かったですが本当にSQLを書く感じでコードが書けたので書き方で悩むこともあまりなかったです。コードを自動生成できるのも実装負担が減りいいと思います。

導入にあたってFlywayとの兼ね合いというかコードの自動生成のタイミングとマイグレーション実行のタイミングが全て自動実行になっているとうまく動かないなどの障壁があるかもしれませんが一回動くところまでできれば問題ないと思います。とりあえず書いていて楽しいので個人的にはこれからはJOOQを使用していこうと思いました！

ぜひみなさんもお試しください！