🐈

[Kotlin] Komapperにおける関連ナビゲーション

2022/06/14に公開

はじめに

まずはKomapperについて簡単に紹介します。

KomapperはサーバーサイドKotlinのためのORMライブラリです。JDBCとR2DBCの2つの接続方式に対応しているのが大きな特徴ですが、その他にもコンパイル時にコード生成をしたりvalue classに対応していたりとユニークな機能もあります。

詳細はWebサイトをご覧ください。
https://www.komapper.org/ja/

この記事では、ORMとは切っても切り離せない「エンティティ間の関連(Association)ナビゲーション」についてKomapperならではの特徴を説明します。

前提

ER図

以下の3つのテーブルがあるとします。

データ

それぞれのテーブルに格納されているデータは次の通りです。

DEPARTMENT

DEPARTMENT_ID DEPARTMENT_NAME
1 ACCOUNTING
2 RESEARCH
3 SALES
4 OPERATIONS

EMPLOYEE

EMPLOYEE_ID EMPLOYEE_NAME SALARY DEPARTMENT_ID ADDRESS_ID
1 SMITH 800.00 2 1
2 ALLEN 1600.00 3 2
3 WARD 1250.00 3 3
4 JONES 2975.00 2 4
5 MARTIN 1250.00 3 5
6 BLAKE 2850.00 3 6
7 CLARK 2450.00 1 7
8 SCOTT 3000.00 2 8
9 KING 5000.00 1 9
10 TURNER 1500.00 3 10

ADDRESS

ADDRESS_ID STREET
1 STREET 1
2 STREET 2
3 STREET 3
4 STREET 4
5 STREET 5
6 STREET 6
7 STREET 7
8 STREET 8
9 STREET 9
10 STREET 10
Komapper用エンティティクラス

テーブルにマッピングするエンティティクラスです。通常のdata classにKomapperのアノテーションを付与しています。

@KomapperEntity
data class Address(
    @KomapperId val addressId: Int,
    val street: String,
)

@KomapperEntity
data class Employee(
    @KomapperId val employeeId: Int,
    val employeeName: String,
    val salary: BigDecimal,
    val addressId: Int,
    val departmentId: Int,
)

@KomapperEntity
data class Department(
    @KomapperId val departmentId: Int,
    val departmentName: String,
)
バージョン情報
  • Kotlin 1.7.0
  • Java 11
  • Komapper 1.1.2

一般的なORMにおける関連ナビゲーション

一般的なJavaやKotlinのORMではエンティティのプロパティにアクセスして関連を辿ることができます。例えば、JPAを使った場合次のように記述できるでしょう。

val departments: List<Department> = query.getResultList()

for (department in departments) {
    println("departmentName: ${department.departmentName}")
    for (employee in department.employees) {
        val address = employee.address
        println("  employeeName: ${employee.employeeName}, salary: ${employee.salary}, street: ${address?.street}")
    }
}

このような機能は一見便利ですが、N+1問題を防ぐために多少煩雑な考慮(fetch戦略や関連のマッピング等)をせねばならず、見た目よりは厄介と言えます。

Komapperの関連ナビゲーション

Komapperでは別のアプローチを採ります。

Komapperはデータを1度で取得し、そのデータをストアと呼ばれるところに格納します。その後、1対1や1対多の関連を表すMapを求めに応じてストアから返します。そのため、N+1問題は起きません。

完全なコードではありませんが、上述の一般的なORMのサンプルコードに対応させる形で抜粋すると次のように記述できます(一部わかりやすさのために型を明示していますが必須ではありません)。

val store: EntityStore = ...
val departmentEmployees: Map<Department, Set<Employee>> = store.oneToMany(d, e)
val employeeAddress: Map<Employee, Address?> = store.oneToOne(e, a)

for ((department, employees) in departmentEmployees) {
    println("departmentName: ${department.departmentName}")
    for (employee in employees) {
        val address = employeeAddress[employee]
        println("  employeeName: ${employee.employeeName}, salary: ${employee.salary}, street: ${address?.street}")
    }
}

ストアが保持するデータのイメージです。

ストアがこのようなデータを保持することを前提にして上述のコードを動かすと次のような出力になります。

departmentName: RESEARCH
  employeeName: JONES, salary: 2975.00, street: STREET 4
  employeeName: SCOTT, salary: 3000.00, street: STREET 8
departmentName: SALES
  employeeName: BLAKE, salary: 2850.00, street: STREET 6
departmentName: ACCOUNTING
  employeeName: CLARK, salary: 2450.00, street: STREET 7
  employeeName: KING, salary: 5000.00, street: STREET 9

上述のコードはdepartmentエンティティからemployeeエンティティを経由してaddressエンティティにアクセスしていますが、departmentエンティティとaddressエンティティの関連のみを抽出することもできます。

val departmentAddresses: Map<Department, Set<Address>> = store.oneToMany(d, a)

for ((department, addresses) in departmentAddresses) {
    println("departmentName: ${department.departmentName}")
    for (address in addresses) {
        println("  street: ${address.street}")
    }
}

このコードを実施すると次のような出力が得られます。

departmentName: RESEARCH
  street: STREET 4
  street: STREET 8
departmentName: SALES
  street: STREET 6
departmentName: ACCOUNTING
  street: STREET 7
  street: STREET 9

つまり、ER図上では直接つながっていなくてもデータを関連づけて扱うことが容易です。

ストアを取得するためのクエリ

先ほど、「Komapperは、データを1度で取得しストアと呼ばれるところに格納します」と述べました。ではストアはどのように入手するのでしょうか?

ストアは以下のようにクエリを実行することで入手できます。

val d = Meta.department
val e = Meta.employee
val a = Meta.address

val query = QueryDsl.from(e)
    .innerJoin(d) { e.departmentId eq d.departmentId }
    .innerJoin(a) { e.addressId eq a.addressId }
    .where { e.salary greaterEq BigDecimal(2_000) }
    .includeAll()

val store: EntityStore = db.runQuery(query)

ポイントはクエリ構築の最後でincludeAll()を呼ぶことです。この呼び出しにより結合したテーブルすべてのカラムのデータを取得できます。includeAll()を呼ばない場合、取得できるのはfrom()に指定したテーブルのデータのみとなり関連を辿れません。

おわりに

エンティティの関連ナビゲーションについてKomapperのアプローチを紹介しました。

ご興味あればぜひKomapperをお試しください。まずはクイックスタートから始めるのがおすすめです。
https://www.komapper.org/ja/docs/quickstart/

Discussion