43
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

MybatisのMapperを使った高度なマッピングー親子関係にあるテーブルの検索結果を格納するResultMap

Last updated at Posted at 2018-10-03

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でしょう。

ItemAndDetailResult.java
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の実行結果をマッピングするクラスを宣言します。

sqldefs.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" 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)をリレーションに沿った型の階層構造にもできます。例えば今回の例では、親レコード側に、子レコードの内容を格納するフィールドを用意します。

Item.java
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;
}
ItemDetail.java
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に宣言します。

sqldefs.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定義でも部分的に再利用できます。便利ですね(・ω・)

ItemSearchRepository.java
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

テーブル定義

V1__CREATE_ITEM_TABLES.sql
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にデータなし。

V2__INSERT_ITEM_TABLES.sql
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" : [ ]
} ]
  1. クラスには、任意のValueObjectや、java.util.HashMap・ArrayListも指定可能。単一の条件を渡したいときはStringやIntegerも可能で、これらのJava標準APIで頻出するクラスやインタフェース、プリミティブ型には エイリアス が提供されています。

  2. 最も簡単なのはjava.util.HashMapで受け取ることですが、これはクエリの結果を格納するのが簡単になる反面、取り出し側は格納した型を正しく追従しないとならなくなってしまうので、あまりお勧めできません。

43
53
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?