🚀

Kotlin の data class と MyBatis の組み合わせ: 問題点と解決策

2023/05/04に公開

背景

Kotlin で MyBatis を利用する際、data class に対して SQL で取得したデータのマッピングが求められることがある。
しかし、単純な実装だけではマッピングが上手くいかないケースが存在する。
本記事では、上手くいかないパターンの理由と対処法を説明する。
本記事のサンプルコードはこちら

tl;dr

  • data class のプライマリコンストラクタの引数の数および順番と、SQL で取得するカラムの数と順番が一致していない場合に、MyBatis でのデータマッピングが失敗しうる
  • 以下の 2 つの対策がある
    • プライマリコンストラクタの全ての引数にデフォルト値を設定する
    • No-arg compiler plugin を利用する
  • No-arg compiler plugin を使うと、非 NULL 許容型にフィールドに NULL が格納されてしまうリスクがあるため、適切なトレードオフをもとに判断する必要がある

うまくいく例

まず、問題なく動作する例を示す。
ここでは、MyBatis の設定はデフォルトのままとする。

NormalUser.kt

data class NormalUser(
    val id: Int,
    val name: String,
    val email: String
)

NormalUserMapper.kt

@Mapper
interface NormalUserMapper {

    @Select("SELECT id, name, email FROM users")
    fun findAll(): List<NormalUser>
}

上記の findAll メソッドでは、期待通り NormalUser クラスに SQL の取得結果がマッピングされる。

うまくいかない例

次に、上手くいかない例を示す。

NormalUserMapper.kt

@Mapper
interface NormalUserMapper {

    @Select("SELECT id, name FROM users")
    fun find2Col(): List<NormalUser>

    @Select("SELECT name, id, email FROM users")
    fun findIncorrectOrder(): List<NormalUser>
}

この例では、find2Col メソッドや findIncorrectOrder メソッドを実行すると、MyBatis が例外を投げる結果になる。

なぜうまくいかないのか?

MyBatis が SQL で取得した結果を格納するためのインスタンスを生成する仕組みが関係している。
デフォルト設定では、MyBatis は以下の手順でインスタンス生成に用いるコンストラクタを探す。

① 引数を取らないコンストラクタが存在する場合、そのコンストラクタを利用してインスタンスを生成し、生成後にフィールドに値をセットする
② 引数を取らないコンストラクタが存在せず、そのクラスに定義されているコンストラクタが 1 つのみの場合、そのコンストラクタを利用してインスタンス生成が試みられる
③ 引数を取らないコンストラクタが存在せず、そのクラスに定義されているコンストラクタが複数の場合、@AutomapConstructor が定義されているコンストラクタを利用してインスタンス生成が試みられる
④@AutomapConstructor が定義されているコンストラクタがない場合、SQL で取得したカラムをその順番でコンストラクタの引数に渡して初期化が可能なコンストラクタを探し、存在する場合にそのコンストラクタでのインスタンス生成が試みられる
参考: ソースコード 1 ソースコード 2

では、data class の場合はどうなるか。今回の例では、プライマリコンストラクタが以下のように定義されている。

 NormalUser(
    val id: Int,
    val name: String,
    val email: String
)

この場合、② に該当し、MyBatis はプライマリコンストラクタを利用してインスタンスを生成しようとする。

上手くいく例では、プライマリコンストラクタの引数の順番と SQL で取得したカラムの順番が一致しているため、正しくインスタンス生成が可能である。
しかし、find2Col の例では、プライマリコンストラクタは 3 つの引数をとるのに対し、SQL の結果は 2 つのカラム分しかないため、エラーが発生する(実行すると java.lang.IndexOutOfBoundsException: Index 2 out of bounds for length 2 という例外が発生する)。

また、findIncorrectOrder の例では、プライマリコンストラクタの引数の順序と SQL の結果の順序が一致せず、Int 型の id に name を割り当てようとしてエラーが発生する(実行すると java.lang.NumberFormatException が発生する)。

Kotlin の data class では、プライマリコンストラクタが必須となるため、この問題が発生しやすい。
data class に限らず、普通の class でも引数付きのコンストラクタを定義すると引数なしのコンストラクタが生成されて同じ問題が発生するし、当然 Java でも同様の問題は発生する。

どうすればいいか?

対策 1: プライマリコンストラクタの引数の数および順番を SQL で取得するカラムの数および順番と一致させる
対策 2: プライマリコンストラクタの全ての引数にデフォルト値を設定する
対策 3: No-arg compiler plugin を利用する

対策 1 は、エラーになるパターンの使い方を避ける対応だ。
ただし、単純なモデルでは SQL と data class を常に対応させれば問題ないが、 以下のようなネストのあるモデルの場合は対応ができない。

data class NormalUser(
    val id: Int,
    val name: String,
    val email: String
)

data class NormalGroupUser(
    val groupId: Int,
    val users: List<NormalUser>
)

この例で、 MyBatis の collection を使って NormalGroupUser に SQL 結果をマッピングしようとする場合は、対策 2 か 対策 3 を取る必要がある。
ネストのないモデルだけしか扱えないというのはかなり厳しいため、対策 1 を取るのは現実的ではないだろう。

対策 2 では、data class のプライマリコンストラクタの全ての引数にデフォルト値を設定することで、引数を取らないコンストラクタが内部で生成されることを利用する。
Kotlin の公式ドキュメントでもこの方法が紹介されている。

On the JVM, if all of the primary constructor parameters have default values, the compiler will generate an additional parameterless constructor which will use the default values. This makes it easier to use Kotlin with libraries such as Jackson or JPA that create class instances through parameterless constructors.

class Customer(val customerName: String = "")

https://kotlinlang.org/docs/classes.html#constructors

引数を取らないコンストラクタが生成されれば、MyBatis はそのコンストラクタを利用してインスタンスを生成し、SQL で取得した値をセットしてくれるため、うまくいく。

(https://qiita.com/chohas/items/20a5477ad3ac0a50bc8c のような方法で、プライマリコンストラクタの全ての引数に対してデフォルト値を設定した data class を Java コードにデコンパイルすると、引数なしのコンストラクタが実際に確認できる)

対策 3 も、対策 2 と同様に引数のないコンストラクタを生成する方法だ。
No-arg compiler pluginを利用して専用のアノテーションを定義し、そのアノテーションを data class に付けることで、引数なしのコンストラクタを MyBatis が利用できるようになる。

ただし、対策 3 では非 NULL 許容型のプロパティに対して、NULL が格納されるリスクがある。
No-arg compiler plugin に自動生成された引数なしのコンストラクタによって生成されるインスタンスは、当然全てのフィールドが(非 NULL 許容型であろうと)NULL で初期化される。
MyBatis はインスタンス生成後に SQL で取得した値を各フィールドに設定するが、SQL でそのフィールドの値が取得されていない場合や、NULL が取得されている場合は、そのまま NULL のままとなってしまうため注意が必要だ。

どの方法を選ぶべきか?

先に述べた通り、対策 1 は現実的ではない。

対策 2 と対策 3 のメリットとデメリットは以下の通りだ。

対策 2

  • メリット
    • プラグインを導入する必要がない
  • デメリット
    • 全てのフィールドにデフォルト値を必ず設定しなければならない - 実装の手間が増える - デフォルト値を設定したくない場合でも設定が必要
      対策 3
  • メリット
    • プラグイン導入後、クラスにアノテーションを付けるだけでよい
  • デメリット
    • プラグインを導入する必要がある
    • 非 NULL 許容型のプロパティに対して、NULL が格納されるリスクがある

常にデフォルト値を設定するルールを遵守するか、NULL が格納されるリスクを許容するかの判断となる。

まとめ

この記事では、Kotlin の data class と MyBatis を組み合わせた際の問題と解決策について検討した。
問題は、Kotlin の data class は必ず引数を持つプライマリコンストラクタを持つため、MyBatis がインスタンスを生成しにくくなることだ。

解決策として 3 つの方法があるが、対策 1 は現実的ではない。
対策 2 はデフォルト値を必ず設定する必要があるが、プラグインを導入する必要はない。
一方、対策 3 はプラグイン導入後にクラスにアノテーションを付けるだけでよいが、非 NULL 許容型のプロパティに対して NULL が格納されるリスクがある。
プロジェクトの要件やチームのルールに応じて、適切な解決策を選ぶことになるだろう。

Discussion