MENU

KotlinのO/R Mapper、Komapperを試してみました

こんにちは。虎の穴ラボのH.Kです。
KotlinのO/R MapperといえばJetBrains製のExposedが有名ですが、DSLを介さないSQL文そのもの(Native SQL)の実行が基本的にはサポートされていない*1など、まだまだ発展途上な面もあります。
また、SQL文をそのまま使うとなると、Doma2が2Way-SQL*2により実現できますが、Kotlinならではの、DSLによるクエリ発行ができません。
※最新のDoma2であればCriteria API*3ができているので、DSLによるクエリ発行は可能になっています。

そこで他のO/R Mapperを探していたところ、Komapperというライブラリを見つけたため、試してみました。

↓Exposed
github.com

↓Doma2
github.com

↓Komapper
github.com

なぜKomapperを試したか

なぜ、Komapperを試そうと思ったかと言うと、下記の2点が挙げられます。

  • DSLでクエリが発行できること(Exposedと同じようにできる)
  • 2Way-SQLにより、SQL文がそのまま実行できること(Doma2と同じようにできる)

KotlinのO/R Mapperライブラリで上記2点の両者に対応したものは少ないと感じています。

使うもの

  • IntelliJ IDEA
  • Kotlin 1.3.61
  • Komapper(H2DB) 0.1.8

Komapperは現在、H2DBとPosgreSQLに対応しています。
今回はローカルでお試しするだけなのでH2DBを使用します。 基本的には以下のGetting Startedに沿って進めていきます。
github.com

準備

Gradleを使って環境を整えます。
Kotlinのプロジェクトを作り、build.gradle に依存関係を記載するだけで使用できるようになります。

repositories {
    mavenCentral()
    jcenter()
}
dependencies {
    // H2DB用のライブラリを依存先に追加する
    implementation "org.komapper:komapper-jdbc-h2:0.1.8"
}

実装

Entityの実装

AddressとEmployeeの2つのEntityを作ります。
Entites.kt

package entity

import org.komapper.core.entity.SequenceGenerator
import org.komapper.core.entity.entities
import java.time.LocalDateTime

// データクラスとしてEntityを定義
data class Address(val id: Int = 0, val street: String, val version: Int = 0, val createdDate:LocalDateTime? = null, val updatedDate:LocalDateTime? = null)
data class Employee(val id: Int = 0, val name:String, val addressId: Int = 0, val version: Int = 0, val createdDate:LocalDateTime? = null, val updatedDate:LocalDateTime? = null)

// テーブル情報をEntityのメタ情報として定義する
val metadata = entities {
    // Addressクラスへの定義
    entity<Address> {
        // IDになるカラムを定義する
        // ADDRESS_SEQ(DBのシーケンス)を使って採番する
        id(Address::id, SequenceGenerator("ADDRESS_SEQ", 100))
        // 楽観的排他制御用のプロパティを指定する
        version(Address::version)
        // テーブルとの紐付けを行う
        // 基本的にはEntityのプロパティ名がそのまま使用されるが変更したい時用
        table {
            // Entityのidプロパティをテーブルのaddress_idとマッピングする
            column(Address::id, "address_id")
        }
        // 作成日時の自動挿入用の設定
        createdAt(Address::createdDate)
        // 更新日時の自動挿入用の設定(Insert時にはnullになる)
        updatedAt(Address::updatedDate)

    }

    // Employeeクラスへの定義
    entity<Employee> {
        // ID、シーケンスの定義
        id(Employee::id, SequenceGenerator("EMP_SEQ", 100))
        // 楽観的排他制御用のプロパティの定義
        version(Employee::version)
        table {
            // EmployeeエンティティとEMPテーブルをマッピングする
            name("EMP")
            // Entityのidプロパティをテーブルのemp_idとマッピングする
            column(Employee::id, "emp_id")
        }
        createdAt(Employee::createdDate)
        updatedAt(Employee::updatedDate)
    }
}

注意点としてはInsert時にupdatedAtで定義したカラムにnullが入る点です。
私がこれまで見てきたプロジェクトですと、テーブルへの挿入時には作成日時=更新日時としているところが多かったため、それを実現するためには別の仕組みを考える必要があります。

CRUDの実装

Main.kt

import entity.*
import org.komapper.core.Db
import org.komapper.core.DbConfig
import org.komapper.core.criteria.select
import org.komapper.core.entity.DefaultEntityMetaResolver
import org.komapper.core.jdbc.SimpleDataSource
import org.komapper.core.sql.template
import org.komapper.jdbc.h2.H2Dialect

fun main() {
    // DB接続を行うインスタンスを作成
    val db = Db(
        // 設定を引数で渡す
        object : DbConfig() {
            // H2DBのデータソース
            override val dataSource = SimpleDataSource("jdbc:h2:mem:example;DB_CLOSE_DELAY=-1")
            // H2DBのDialect(文法情報)
            override val dialect = H2Dialect()
            // Entityで定義したメタ情報
            override val entityMetaResolver = DefaultEntityMetaResolver(metadata)
        }
    )

    // DBの初期設定を行う
    db.transaction {
        // ADDRESSテーブルの作成
        db.execute(
            """
            CREATE SEQUENCE ADDRESS_SEQ START WITH 1 INCREMENT BY 100;
            CREATE TABLE ADDRESS(
                ADDRESS_ID INTEGER NOT NULL PRIMARY KEY,
                STREET VARCHAR(20) UNIQUE,
                VERSION INTEGER,
                CREATED_DATE DATE,
                UPDATED_DATE DATE
            );
            """.trimIndent()
        )
        // EMPテーブルの作成
        db.execute(
            """
            CREATE SEQUENCE EMP_SEQ START WITH 1 INCREMENT BY 100;
            CREATE TABLE EMP(
                EMP_ID INTEGER NOT NULL PRIMARY KEY,
                NAME VARCHAR(20) UNIQUE,
                ADDRESS_ID INTEGER,
                VERSION INTEGER,
                CREATED_DATE DATE,
                UPDATED_DATE DATE
            );
            """.trimIndent()
        )
    }

    // CRUDをトランザクション内で実施
    db.transaction {
        // レコードのInsert(1)
        val addressA = db.insert(Address(street = "street A"))
        // レコードのInsert(2)
        val addressZ = db.insert(Address(street = "street Z"))

        // 検索(3)
        val foundA = db.findById<Address>(1)

        // 更新(4)
        val addressB = db.update(addressA.copy(street = "street B"))

        // DSLを使った検索
        val criteriaQuery = select<Address> {
            where {
                eq(Address::street, "street B")
            }
        }
        // 作成したクエリを実行し、1件目を取得する(5)
        val foundB1 = db.select(criteriaQuery).first()

        // 2Way-SQLを使った検索(6)
        val templateQuery = template<Address>(
            "select /*%expand*/* from Address where street = /*street*/'test'",
            object {
                val street = "street B"
            }
        )
        val foundB2 = db.select(templateQuery).first()
        // 2Way-SQLを使った検索_いろいろ使ってみる(7)
        val templateQuerySample = template<Address>(
            "select /*%expand*/* from Address where street = /*street*/'test' or address_id in /* idList */(1) /*%if !orderBy.isEmpty() */ /*# orderBy */ /*%end*/",
            object {
                // バインド変数
                val street = "street B"
                // Iterableの変数
                val idList = mutableListOf(1,2)
                // 埋め込み変数(直接埋め込まれる)
                val orderBy = "order by address_id desc"
            }
        )
        val foundB3 = db.select(templateQuerySample).first()


        val empA = db.insert(Employee(name = "user A",addressId = addressA.id))
        val map = mutableMapOf<Employee, Address?>()
        // リレーションのあるレコードの検索(8)
        db.select<Employee> { e ->
            innerJoin<Address> { a ->
                eq(e[Employee::addressId], a[Address::id])
                oneToOne { employee, address -> map[employee] = address }
            }
            where { eq(Employee::id,1) }
        }
        map.forEach{ (e, a) -> println("$e $a")}
        
        // 全件検索(9)
        val addressList = db.select<Address>()

        // レコードの削除(10)
        db.delete(addressB)
        // レコードの削除(11)
        db.delete(addressZ)

        // DSLを使ったレコードのInsert(12)
        val count = db.insert<Address> {
            values {
                value(Address::id, 3)
                value(Address::street, "street C")
                value(Address::version, 0)
                value(Address::createdDate, LocalDateTime.now())
                value(Address::updatedDate, LocalDateTime.now())
            }
        }

        // DSLを使ったレコードの更新(13)
        val updateCriteriaQuery = update<Address> {
            set {
                value(Address::street,"street D")
            }
            where {
                eq(Address::street, "street C")
            }
        }
        db.update(updateCriteriaQuery)
    }
}

Main.kt#mainを実行した際に発行されるクエリは以下の通りです。
番号はソースコードのコメント部分のカッコ内の数字に対応します。

  1. insert into address (address_id, street, version, created_date, updated_date) values (1, 'street A', 0, '2020-04-30T15:34:36.852', null)
  2. insert into address (address_id, street, version, created_date, updated_date) values (2, 'street Z', 0, '2020-04-30T15:34:36.876', null)
  3. select t0_.address_id, t0_.street, t0_.version, t0_.created_date, t0_.updated_date from address t0_ where t0_.address_id = 1
  4. update address t0_ set street = 'street B', version = 1, created_date = '2020-04-30T15:34:36.852', updated_date = '2020-04-30T1534:36.917' where t0_.address_id = 1 and t0_.version = 0
  5. select t0_.address_id, t0_.street, t0_.version, t0_.created_date, t0_.updated_date from address t0_ where t0_.street = 'street B'
  6. select address_id, street, version, created_date, updated_date from Address where street = 'street B'
  7. select address_id, street, version, created_date, updated_date from Address where street = 'street B' or address_id in (1, 2)  order by address_id desc
  8. select t0_.emp_id, t0_.name, t0_.address_id, t0_.version, t0_.created_date, t0_.updated_date, t1_.address_id, t1_.street, t1_.version, t1_.created_date, t1_.updated_date from EMP t0_ inner join address t1_ on (t0_.address_id = t1_.address_id) where t0_.emp_id = 1
  9. select t0_.address_id, t0_.street, t0_.version, t0_.created_date, t0_.updated_date from address t0_
  10. delete from address t0_ where t0_.address_id = 1 and t0_.version = 1
  11. delete from address t0_ where t0_.street = 'street Z'
  12. insert into address (address_id, street, version, created_date, updated_date) values (3, 'street C', 0, '2020-04-30T15:34:37.074', '2020-04-30T15:34:37.074')'
  13. update address t0_ set street = 'street D' where t0_.street = 'street C'

注意点としてはInsertに際して、シーケンスを使った採番とDSLによるInsertを混在させることができない点です。 DSLによるInsertはシーケンスによる発行ができないため、上記の最後に、以下の処理を追加すると

db.insert(Address(street = "street E"))

Exception in thread "main" org.komapper.core.UniqueConstraintException: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: ユニークインデックス、またはプライマリキー違反: "PRIMARY KEY ON PUBLIC.ADDRESS(ADDRESS_ID)という主キーの重複エラーが発生します。
このエラーメッセージ、見ての通りなんと日本語で出力されます。
これは日本人の方が開発されている良いところであり、日本人としてはありがたい限りです。

また、7の2Way-SQLによる検索についてですが、公式ドキュメントにはバインド変数しか記載がありません。
しかし試した限り、Doma2と同じようなものが使えるみたいです。
例えば/*%expand*/**と同じ意味になります。
↓参考:Doma2で使えるSQL コメント
doma.readthedocs.io

今回試したものとは直接関係はしませんが、2Way-SQLの記述に関して、通常のSQL整形ツールではコメント部分で改行され、正しくパースできない可能性があります。
そのため、uroboroSQL formatter(ウロボロスキュール)というフォーマッターを使うと便利です。

github.com

まとめ

今回、Komapperを試してみたところ、Doma2の影響を大きく受けつつ、よりKotlinらしく簡潔にDBアクセスできるように作られているなと感じました。
更新、削除時には2Way-SQLの使用がまだできないようなので今後の機能拡張に期待していきたいと思います。

P.S.

虎の穴ラボでの開発に少しでも興味を持っていただけた方は、採用説明会やカジュアル面談という場でもっと深くお話しすることもできます。ぜひお気軽に申し込みいただければ幸いです。
虎の穴ラボのエンジニアが、開発プロセスの内容であったり、「今季何見ました?」といったオタクトークであったり、何でもお応えします。

カジュアル面談や採用情報はこちらをご確認ください。
yumenosora.co.jp

※現在はオンラインカジュアル面談のみ受け付けております。(2020/5/8時点)
現在行っているオンラインによるWeb面談についてはこちらの記事をご参照ください。
toranoana-lab.hatenablog.com

5月14日に「とらのあな採用説明会 5/14 オタク企業で働くエンジニアの魅力について」オンライン会社説明会を開催します。

yumenosora.connpass.com

また、5月27日には、「【オンライン開催】リモートワークノウハウLT【とらのあなLT】」を開催します。定例LT会ですが、今回はリモートワークに的を絞った内容となります。人数制限はございませんのでぜひご参加ください!

yumenosora.connpass.com

*1:FAQ · JetBrains/Exposed Wiki · GitHub

*2:SQLクライアントツールとプログラムからのバインドの両方に対応したSQLの記述形式

*3:https://doma.readthedocs.io/en/latest/criteria-api/