MyBatisの特徴
MyBatis は、JavaのO/Rマッピングフレームワークの1つで、SQLをそのまま扱いながら動的に構文を変える 動的SQL が特徴で、既存のSQLコードを資産として流用もしやすいです。
他にも、検索時に渡す条件やSQLの実行結果を任意のJavaクラス1で扱える、また一方ではデータベースとJavaクラスの型変換を定義する TypeHandler があるため、煩雑になりがちな型変換に煩わされることがありません。
今回はその中でもリレーショナルデータベースの検索で頻繁に使う「リレーションを使った親子関係にあるテーブルの検索結果をひとまとめにする」MyBatisの高度なmapper機能を紹介します。
親子関係にあるテーブルの検索とJavaコード
今回のサンプルは、簡素な親子関係にあるテーブル2つを取り上げます。以下は、扱う商品の親データとその明細情報を扱うテーブルです。明細には親データの品名に属する詳細な品名が属しており、親子関係にあります。
商品名(ITEM)
カラム名 | 型 | 概要 |
---|---|---|
ID | INT | 商品の親データを一意に決定する番号 |
NAME | VARCHAR(256) | 親商品の名前 |
商品明細(ITEM_DETAIL)
カラム名 | 型 | 概要 |
---|---|---|
ID | INT | 商品の親データを一意に決定する番号 |
DETAIL_ID | VARCHAR(8) | 明細商品のID |
DETAIL_NAME | VARCHAR(256) | 明細品の名前 |
PRICE | INT | 単価 |
SALES_START | DATE | 販売開始日 |
SALES_END | DATE | 販売終了日 |
ITEMのIDとITEM_DETAILのIDが一致したレコードが親子関係にあります。テーブル定義と投入データは、末尾のAppendixに記載しています。
SQL
親(商品)テーブルと、子(商品詳細)テーブルともにレコードが存在するデータを取得するSQLは、例えば次のようなもので記述します。
SELECT
item.ID AS id,
item.NAME AS name,
detail.DETAIL_ID AS detailId,
detail.DETAIL_NAME AS detailName,
detail.PRICE AS price,
detail.SALE_START AS saleStart,
detail.SALE_END AS saleEnd
FROM
ITEM item
INNER JOIN
ITEM_DETAIL detail
ON
item.ID = detail.ID
このSQLを実行した結果を格納するクラスは、次のように1つのDTO(DataTransferObject)にすべてのフィールドと型を用意して格納するのが簡単2でしょう。
package com.github.apz.springsample.model
import java.time.LocalDate;
import lombok.Data;
@Data
public class ItemAndDetailResult {
private int id;
private String name;
private String detailId;
private String detailName;
private int price;
private LocalDate saleStart;
private LocalDate saleEnd;
}
このItemAndDetailResultに検索結果をマッピングするMyBatisの定義は以下のようになるでしょう。
resultTypeに、SQLの実行結果をマッピングするクラスを宣言します。
<?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="items">
<select id="selectItem" resultType="com.github.apz.springsample.model.ItemAndDetailResult">
SELECT
item.ID AS id,
item.NAME AS name,
detail.DETAIL_ID AS detailId,
detail.DETAIL_NAME AS detailName,
detail.PRICE AS price,
detail.SALE_START AS saleStart,
detail.SALE_END AS saleEnd
FROM
ITEM item
INNER JOIN
ITEM_DETAIL detail
ON
item.ID = detail.ID
</select>
</mapper>
これはこれで十分なのですが、実はMyBatisのマッピング機能には 親子関係・リレーションを維持してマッピングできる 機能があります。この機能がある理由については MyBatisは次のように言及 しています。現実はつらいですね。
MyBatis は「データベースは必ずしも希望通りに定義されている訳ではない」という思想に基づいて設計されています。 すべてのデータベースが完全な第三正規形あるいは BCNF なら最高ですが、実際はそうではありません。 また、たったひとつで全てのアプリケーションに適合できるようなデータベースを作ることができたら素晴らしいですが、これも現実とは異なります。 こうした問題に対する MyBatis の答えが Result Map です。
親レコード1に対し子レコードが複数ある場合のマッピング
ResultMapを使ったマッピングを利用した場合、この結果を受け取るJavaクラス(DataTransferObject)をリレーションに沿った型の階層構造にもできます。例えば今回の例では、親レコード側に、子レコードの内容を格納するフィールドを用意します。
package com.github.apz.springsample.model;
import java.util.List;
import lombok.Data;
@Data
public class Item {
private int id;
private String name;
/** 子要素を複数持つ */
private List<ItemDetail> details;
}
package com.github.apz.springsample.model;
import java.time.LocalDate;
import lombok.Data;
@Data
public class ItemDetail {
private int id;
private String detailId;
private String detailName;
private int price;
private LocalDate saleStart;
private LocalDate saleEnd;
}
このマッピング先のクラスに対するSQLマッピング定義のxmlは、SQL文の内部は一切変えなくて済みます。resultType 属性 を ResultMap用にresultMap 属性に変え、 そしてマッピング先として宣言したresultMapであるitemResultの定義もSQLマッピング定義のxmlに宣言します。
<?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="items">
<select id="selectItem" resultMap="itemResult">
SELECT
item.ID AS id,
…(中略)…
FROM
ITEM item
INNER JOIN
ITEM_DETAIL detail
ON
item.ID = detail.ID
</select>
<resultMap type="com.github.apz.springsample.model.Item" id="itemResult">
<id column="id" property="id"/>
<result column="name" property="name" />
<collection property="details" ofType="com.github.apz.springsample.model.ItemDetail">
<result column="detailId" property="detailId"/>
<result column="detailName" property="detailName"/>
<result column="price" property="price"/>
<result column="saleStart" property="saleStart"/>
<result column="saleEnd" property="saleEnd"/>
</collection>
</resultMap>
</mapper>
resultMapの定義では、SQLの実行結果のカラム名 column 属性と対応するJavaクラスのフィールド名 property を必ず指定します。
親テーブル側が持っている子テーブルの複数レコードを List details で保持していますので、resultMapでもこの関係性を宣言します。
こうすることで初めから階層構造をもつクエリの実行結果が得られます。repositoryの実装例は次のようになりますので、検索した後に階層構造へ成形しなおす手間が省けますし、検索結果が増えるたびにクラスが増えることもありません。またmapping要素は他のSQL定義でも部分的に再利用できます。便利ですね(・ω・)
package com.github.apz.springsample.repository;
import java.util.List;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.stereotype.Repository;
import com.github.apz.springsample.model.Item;
import lombok.RequiredArgsConstructor;
@Repository
@RequiredArgsConstructor
public class ItemSearchRepository {
private final SqlSessionTemplate sqlSessionTemplate;
public List<Item> searchAllItems() {
return sqlSessionTemplate.selectList("items.selectItem");
}
}
LEFT JOINしたときに子テーブルに該当レコードがなかった場合
空のCollectionが入ります。今回の例では List のインスタンスはありますが、要素数が0です。
Appendixの LEFT JOIN 利用時の検索結果(Jacksonで変換) を参照してください。
Appendix
参考文献・参考サイト
http://www.mybatis.org/mybatis-3/ja/index.html
動作環境
- SpringBoot 2.0.5
- Mybatis 3.4.6
- AdoptOpenJDK11_x64_windows_hotspot_2018-09-25-00-07 ( Pleiades All in one Eclipse 2018-09同梱版 )
- Groovy 2.4.15
サンプルコード
GitHubに一式公開しております。Jacksonを使った検索結果の出力テストはdevelopブランチに格納しています。
https://github.com/A-pZ/springboot-mybatis-mapper-sample
テーブル定義
CREATE TABLE ITEM (
ID INT NOT NULL,
NAME VARCHAR(256) NOT NULL
);
CREATE TABLE ITEM_DETAIL (
ID INT NOT NULL,
DETAIL_ID VARCHAR(8) NOT NULL,
DETAIL_NAME VARCHAR(256) NOT NULL,
PRICE INT NOT NULL,
SALE_START DATE(8),
SALE_END DATE(8)
);
投入データSQL
IDが3の場合、ITEM_DETAILにデータなし、IDが4の場合、ITEMにデータなし。
INSERT INTO ITEM (ID, NAME) VALUES (1, '肉製品');
INSERT INTO ITEM (ID, NAME) VALUES (2, '野菜');
INSERT INTO ITEM (ID, NAME) VALUES (3, '穀物');
INSERT INTO ITEM_DETAIL (ID, DETAIL_ID, DETAIL_NAME, PRICE, SALE_START, SALE_END) VALUES (1, 'a1', 'ロース', 800, '2018-09-01', '2018-09-30');
INSERT INTO ITEM_DETAIL (ID, DETAIL_ID, DETAIL_NAME, PRICE, SALE_START, SALE_END) VALUES (1, 'a2', 'スナズリ', 900, '2018-09-11', '2018-10-30');
INSERT INTO ITEM_DETAIL (ID, DETAIL_ID, DETAIL_NAME, PRICE, SALE_START, SALE_END) VALUES (1, 'a3', 'リブロース', 1000, '2018-09-21', '2018-11-30');
INSERT INTO ITEM_DETAIL (ID, DETAIL_ID, DETAIL_NAME, PRICE, SALE_START, SALE_END) VALUES (2, 'b1', 'ほうれんそう', 300, '2018-09-01', '2018-09-30');
INSERT INTO ITEM_DETAIL (ID, DETAIL_ID, DETAIL_NAME, PRICE, SALE_START, SALE_END) VALUES (2, 'b2', 'はくさい', 200, '2018-09-11', '2018-10-30');
INSERT INTO ITEM_DETAIL (ID, DETAIL_ID, DETAIL_NAME, PRICE, SALE_START, SALE_END) VALUES (4, 'd1', 'りんご', 250, '2018-09-01', '2018-11-30');
INSERT INTO ITEM_DETAIL (ID, DETAIL_ID, DETAIL_NAME, PRICE, SALE_START, SALE_END) VALUES (4, 'b2', 'なし', 350, '2018-10-01', '2018-11-30');
LEFT JOIN 利用時の検索結果(Jacksonで変換)
[ {
"id" : 1,
"name" : "肉製品",
"details" : [ {
"id" : 0,
"detailId" : "a1",
"detailName" : "ロース",
"price" : 800,
"saleStart" : {
"year" : 2018,
"month" : "SEPTEMBER",
"monthValue" : 9,
"dayOfMonth" : 1,
"leapYear" : false,
"dayOfWeek" : "SATURDAY",
"dayOfYear" : 244,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
},
"saleEnd" : {
"year" : 2018,
"month" : "SEPTEMBER",
"monthValue" : 9,
"dayOfMonth" : 30,
"leapYear" : false,
"dayOfWeek" : "SUNDAY",
"dayOfYear" : 273,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
}
}, {
"id" : 0,
"detailId" : "a2",
"detailName" : "スナズリ",
"price" : 900,
"saleStart" : {
"year" : 2018,
"month" : "SEPTEMBER",
"monthValue" : 9,
"dayOfMonth" : 11,
"leapYear" : false,
"dayOfWeek" : "TUESDAY",
"dayOfYear" : 254,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
},
"saleEnd" : {
"year" : 2018,
"month" : "OCTOBER",
"monthValue" : 10,
"dayOfMonth" : 30,
"leapYear" : false,
"dayOfWeek" : "TUESDAY",
"dayOfYear" : 303,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
}
}, {
"id" : 0,
"detailId" : "a3",
"detailName" : "リブロース",
"price" : 1000,
"saleStart" : {
"year" : 2018,
"month" : "SEPTEMBER",
"monthValue" : 9,
"dayOfMonth" : 21,
"leapYear" : false,
"dayOfWeek" : "FRIDAY",
"dayOfYear" : 264,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
},
"saleEnd" : {
"year" : 2018,
"month" : "NOVEMBER",
"monthValue" : 11,
"dayOfMonth" : 30,
"leapYear" : false,
"dayOfWeek" : "FRIDAY",
"dayOfYear" : 334,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
}
} ]
}, {
"id" : 2,
"name" : "野菜",
"details" : [ {
"id" : 0,
"detailId" : "b1",
"detailName" : "ほうれんそう",
"price" : 300,
"saleStart" : {
"year" : 2018,
"month" : "SEPTEMBER",
"monthValue" : 9,
"dayOfMonth" : 1,
"leapYear" : false,
"dayOfWeek" : "SATURDAY",
"dayOfYear" : 244,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
},
"saleEnd" : {
"year" : 2018,
"month" : "SEPTEMBER",
"monthValue" : 9,
"dayOfMonth" : 30,
"leapYear" : false,
"dayOfWeek" : "SUNDAY",
"dayOfYear" : 273,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
}
}, {
"id" : 0,
"detailId" : "b2",
"detailName" : "はくさい",
"price" : 200,
"saleStart" : {
"year" : 2018,
"month" : "SEPTEMBER",
"monthValue" : 9,
"dayOfMonth" : 11,
"leapYear" : false,
"dayOfWeek" : "TUESDAY",
"dayOfYear" : 254,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
},
"saleEnd" : {
"year" : 2018,
"month" : "OCTOBER",
"monthValue" : 10,
"dayOfMonth" : 30,
"leapYear" : false,
"dayOfWeek" : "TUESDAY",
"dayOfYear" : 303,
"era" : "CE",
"chronology" : {
"id" : "ISO",
"calendarType" : "iso8601"
}
}
} ]
}, {
"id" : 3,
"name" : "穀物",
"details" : [ ]
} ]