MyBatis のアノテーション記法を使った、ネストされたオブジェクトのマッピング

こんにちは。株式会社 Interfamilia の Waka です。久しぶりのブログ更新になります!

気づけばもう7月。梅雨が終わり、うだるような暑さが続いています。う~ん…暑い!!
体調を崩したり、熱中症にならないよう注意したいですね💦

それから、2022年に入って新しいプロジェクトに参画するなど、当社の開発業務にも様々な変化がありました!
優秀なエンジニアの方々に囲まれ、日々充実した時間を過ごしております。

今回の記事では、そんな自分が日々の開発業務で学んだ、MyBatisに関するTIPSをご紹介したいと思います!


目次

XMLファイルでの実装/アノテーションを使った実装

MyBatisは、データベースとJavaアプリケーションを紐づけるO/Rマッパーフレームワークです。
XMLファイル等に記載したSQLと、Javaオブジェクトとをマッピングすることができます。
Javaだけでなく、Kotlinでも使えるので、様々な場面で使われています。

通常、MyBatisのMapperは、次の組み合わせで定義します。

  • @Mapperを付与したinterfaceクラス
  • 同一パッケージ階層に置かれたXMLファイル

XMLファイルでSELECT文を実装する場合、次のように書けます。

1
2
3
4
5
<select id="findById" resultType="User">
SELECT id, username, password
FROM user
WHERE id = #{id}
</select>

一方、アノテーションを使い、@Mapperの interfaceクラスに直接SQLを記述する方法もあります。

先ほどのSELECT文は、アノテーション記法であれば次のように書けます。

1
2
@Select("SELECT id, username, password FROM user WHERE id = #{id}")
fun findById(@Param("id") long id): User

ちなみに、XMLファイル・アノテーションの併用も可能です。
アノテーション記法で@Mapperクラスに直接SQLを書き、XMLファイルでresultMapを指定する…といった運用もできます。


1つのカラムをkeyにして、Listに集約するマッピング

MyBatisは、ResultMapを指定することで、ネストしたオブジェクト・リストへのマッピングも可能です。
これはXMLファイルでの実装、アノテーションでの実装、どちらも対応しています。

  • XMLファイルでの実装

    • <association>:ネストしたオブジェクト
    • <collection>:リスト
  • アノテーションでの実装

    • @One:ネストしたオブジェクト
    • @Many:リスト

シンプルな構造のオブジェクト・リストであれば、上の設定項目だけで十分対応できます。
問題となるのは、次のような複雑なマッピングを行いたい時です。

【Before】 (元データ)

typename
ほのおヒトカゲ
ほのおアチャモ
ほのおヒバニー
みずワニノコ
みずポッチャマ

【After】 (マッピング後)

typenameList
ほのおlistOf(ヒトカゲ, アチャモ, ヒバニー)
みずlistOf(ワニノコ, ポッチャマ)

上の例では、type を keyに指定して、各データを集約した上で Listにしています。

自分はアノテーション記法のMapper実装では、 こうした複雑なマッピングは処理できない!と思い込んでいました。
普段こうしたマッピングを行う際には、XMLファイルで <resultMap> を指定することが多かったからです。

しかし、実際に検証して確かめたところ…アノテーション記法のMapper実装でも、複雑なマッピング (1つのカラムを key にして Listに集約する) を処理可能だと分かりました!

以下、実際の検証内容を紹介します。


検証

バージョン・環境

Java:11
Kotlin : 1.5.0
Spring Boot:2.5.0
MyBatis:mybatis-spring-boot-starter:2.2.0
MySQL : 5.7

テストコード

テーブル定義

2つのテーブルを用意しました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
CREATE TABLE prefecture (
    `prefecture_id` int UNSIGNED NOT NULL AUTO_INCREMENT,
    `prefecture_name` varchar(100) DEFAULT NULL COMMENT '県名',
    PRIMARY KEY (`prefecture_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '都道府県';

CREATE TABLE person (
    `person_id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
    `prefecture_id` int UNSIGNED NOT NULL COMMENT '都道府県ID',
    `person_name` varchar(200) DEFAULT NULL COMMENT '人名',
    `person_age` int DEFAULT 0 COMMENT '年齢',
    PRIMARY KEY (`person_id`),
    CONSTRAINT fk_person__prefecture FOREIGN KEY (`prefecture_id`) REFERENCES prefecture (`prefecture_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '人';

Model

2つのModelクラスを定義しました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.example.booking.model

// 著名な都道府県民
data class PrefectureCelebrities(
    var prefectureId: Int? = null,
    var personList: List<Person> = mutableListOf()
)

// 人
data class Person(
    var name: String? = null,
    var age: Int? = null
)

PersonMapper.kt

2パターンのMapperを用意しました。どちらも@Select (アノテーション記法) でクエリを書いています。

  • selectPrefectureCelebritiesWithXml: XMLファイル・アノテーション併用のMapper
  • selectPrefectureCelebritiesWithMany: アノテーションだけで実装したMapper

@Many は、selectPrefectureCelebritiesWithMany@Results 配下に指定しています。 Kotlinでは、ネストしたアノテーションの@を省略するので many=Many(select="selectPerson") のように記述します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package com.example.booking.mapper

import com.example.booking.model.PrefectureCelebrities
import com.example.booking.model.Person
import org.apache.ibatis.annotations.*

@Mapper
interface PersonMapper {

    // XMLファイル・アノテーション併用のMapper
    @Select(
        """
            <script>
                SELECT
                    prefecture_id,
                    person_name,
                    person_age
                FROM
                    person
                WHERE
                    person_id IN
                    <foreach item="id" collection="idList" separator="," open="(" close=")">
                        #{id}
                    </foreach>
                ORDER BY prefecture_id, person_id
            </script>               
        """
    )
    @ResultMap("com.example.booking.mapper.PersonMapper.prefectureCelebrities")
    fun selectPrefectureCelebritiesWithXml(
        @Param("idList") idList: List<Int>
    ): List<PrefectureCelebrities>

    // アノテーションだけで実装したMapper
    @Select(
        """
            <script>
                SELECT
                    prefecture_id
                FROM
                    person
                WHERE
                    person_id IN
                    <foreach item="id" collection="idList" separator="," open="(" close=")">
                        #{id}
                    </foreach>
                GROUP BY prefecture_id
                ORDER BY prefecture_id
            </script>
        """
    )
    @Results(
        Result(
            property="prefectureId",
            column = "prefecture_id"
        ),
        Result(
            property="personList",
            column="prefecture_id",
            javaType= List::class,
            many=Many(select="selectPerson")
        )
    )
    fun selectPrefectureCelebritiesWithMany(
        @Param("idList") idList: List<Int>
    ): List<PrefectureCelebrities>

    // @Manyで必要になるSELECT文
    @Select(
        """
            SELECT
                person_name,
                person_age
            FROM
               person
            WHERE
                prefecture_id = #{prefectureId}
            ORDER BY person_id
        """
    )
    @Results(
        Result(
            property = "name",
            column = "person_name"),
        Result(
            property = "age",
            column = "person_age"
        )
    )
    fun selectPerson(
        @Param("prefectureId") prefectureId: Long
    ): Person
}

PersonMapper.xml

selectPrefectureCelebritiesWithXml の resultMap を定義したXMLファイルです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.booking.mapper.PersonMapper">
    <resultMap id="prefectureCelebrities" type="com.example.booking.model.PrefectureCelebrities">
        <id property="prefectureId" column="prefecture_id" />
        <collection property="personList" ofType="com.example.booking.model.Person">
            <result property="name" column="person_name" />
            <result property="age" column="person_age" />
        </collection>
    </resultMap>
</mapper>

prefecture_celebrities.sql (テストデータ)

PersonMapperTestで使うテスト用データです。 都道府県と、それに紐づく著名人のデータを投入しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- 都道府県
INSERT INTO prefecture
SET prefecture_id = 1,
prefecture_name = '兵庫県';

INSERT INTO prefecture
SET prefecture_id = 2,
prefecture_name = '大阪府';

-- 著名人
INSERT INTO person
SET person_id = 1,
prefecture_id = 1,
person_name = '兵庫太郎',
person_age = 58 ;

INSERT INTO person
SET person_id = 2,
prefecture_id = 1,
person_name = '兵庫次郎',
person_age = 58;

INSERT INTO person
SET person_id = 3,
prefecture_id = 1,
person_name = '兵庫三郎',
person_age = 54;

INSERT INTO person
SET person_id = 4,
prefecture_id = 2,
person_name = '大阪四郎',
person_age = 50;

INSERT INTO person
SET person_id = 5,
prefecture_id = 2,
person_name = '大阪五郎',
person_age = 50;

PersonMapperTest.kt

Mapperの単体テストを行うテストクラスです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.booking.mapper

import com.example.booking.config.MyBatisTestConfig
import org.assertj.core.api.BDDAssertions
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mybatis.spring.annotation.MapperScan
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.context.annotation.Import
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.jdbc.Sql
import kotlin.test.assertNotNull

@MybatisTest
@MapperScan
@ContextConfiguration(classes = [MyBatisTestConfig::class])
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ExtendWith(SoftAssertionsExtension::class)
@Import(MyBatisTestConfig::class)
class PersonMapperTest {

    @Autowired
    lateinit var personMapper: PersonMapper

    @Test
    @Sql(scripts = ["classpath:/sql/prefecture_celebrities.sql"])
    fun selectPrefectureCelebritiesWithXml() {
        val idList = listOf(1, 2, 3, 4, 5)
        val result = personMapper.selectPrefectureCelebritiesWithXml(idList)
        assertNotNull(result)
        // XMLで記述したresultMapによって、prefectureIdで集約される
        BDDAssertions.then(result).hasSize(2)
        BDDAssertions.then(result[0].personList).hasSize(3)
        BDDAssertions.then(result[1].personList).hasSize(2)
    }

    @Test
    @Sql(scripts = ["classpath:/sql/prefecture_celebrities.sql"])
    fun selectPrefectureCelebritiesWithMany() {
        val idList = listOf(1, 2, 3, 4, 5)
        val result = personMapper.selectPrefectureCelebritiesWithMany(idList)
        assertNotNull(result)
        // GROUP BY を使うことで prefectureId で集約できる
        BDDAssertions.then(result).hasSize(2)
        BDDAssertions.then(result[0].personList).hasSize(3)
        BDDAssertions.then(result[1].personList).hasSize(2)
    }
}

検証結果

2つのMapperTestで、全く同じ結果が得られました

  • PrefectureCelebrities への集約がうまく行われ size=2 になる
  • 子要素の List<Person> に集約されたデータ (著名人リスト) が入り、空のListにはならない

XMLファイル・アノテーション併用のMapper

まず、XMLファイル・アノテーション併用のMapper selectPrefectureCelebritiesWithXml です。
こちらは XML側で指定した <resultMap> により、prefectureId を key にした List集約を実現しています。

下のスクショ画像は、デバッグモードでテストを実行した際のものです。
兵庫県 (prefectureId=1)大阪府 (prefectureId=2) に紐づく著名人が prefecture を key に分類され、List化されています。

XMLファイル・アノテーションの併用でマッピング
XMLファイル・アノテーションの併用でマッピング

アノテーションだけで実装したMapper

次に、アノテーション記法だけで実装した selectPrefectureCelebritiesWithMany を見てみます。
こちらは @Many で指定したSELECT文 selectPersonで、著名人リストを取得。
GROUP BY を使って prefectureId を keyに集約しています。

下のスクショ画像は、デバッグモードでテストを実行した際のものです。
selectPrefectureCelebritiesWithXml と同じ結果が得られていることが分かります。

アノテーションだけの実装でも、GROUP BYで集約すればマッピングできる
アノテーションだけの実装でも、GROUP BYで集約すればマッピングできる

最後に

以上、MyBatisで1つのカラムをkeyにして、Listに集約するマッピングについての検証記事でした。

MyBatisのアノテーション記法に関する知識は、ネット上であまり情報を見かけないので、今回の検証で新たな知見を得られたように思います。
他に何か発見がありましたら、当ブログで共有したいと思います!

それではまた次回!


参考リンク


会社を一緒に盛り上げてくれる仲間を募集しています!

応募フォームには、外部サービスengageを利用しています。
ご応募に際して、何かわからないことなどございましたら こちら からお気軽にお問い合わせください。