MybatisのTypeHandlerでEnumを扱う

# 概要
O/Rマッピングフレークワークの [Mybatis](http://www.mybatis.org/mybatis-3/ja/) にて、Mybatisに備わっている自動型変換の機構：TypeHandlerを利用してEnumを扱う動的SQLの記述方法と、SpringMVCを使った応用も紹介します。

## まとめた理由
これから新しく作る・すでに作成済みのシステムやアプリケーションが利用するデータベースには必ずあるかと思われる「フラグ」や「区分値」を扱うカラム[^1]があり、その値の範囲はほぼ不変 [^2]です。
これらの値が文字列型(CHARやVARCHAR)や数値型で扱われているケースも往々にしてありますので、せめてJavaコードの世界では文字列や数値をそのまま扱うことなく、さらには値の範囲が決まっているのであればenumで扱えないか…？ が発端です。

## 記事の前提
本記事中は以下のバージョンで作成・動作確認をしています。

* Java 8u152 (1.8.0_152)
* Spring-Boot 1.5.9.RELEASE
* mybatis-spring-boot-starter 1.3.1
    * mybatis 3.4.5
    * mybatis-spring 1.3.1
* Lombok 1.16.18

## ソースファイル一式
GitHubにて公開しています。

[https://github.com/A-pZ/mybatis-spring-enum-sample](https://github.com/A-pZ/mybatis-spring-enum-sample)

# 検索機能の実装
今回紹介するのは、とあるフラグや区分値を扱っているテーブルを検索した結果を返す機能です。

## 実装する内容

実装する内容をかんたんに説明すると、

* データべースのフラグや区分値を表すenumを作成する
* 作成したenum用の自動型変換クラス(TypeHandler)を作成する
* TypeHandlerを登録する

です。

## 参照するテーブル
今回参照するテーブルの定義[^3]です。

商品テーブル ( テーブル名：item )

|カラム名|型|カラムの役割|制約など|
|:---|:---|:---|:---|
|id|数値|商品を一意に定義する主キー|自動採番される値|
|name|文字列|商品名|商品名の表示に使う|
|status|文字列|商品の表示区分|0:ステータスなし 1:一般公開 2:会員のみ|
|display|文字列|商品の表示・非表示|0:表示しない 1:表示する|

今回フォーカスを当てるのは、statusで定義される3値の区分値、displayで扱うフラグです。

## フラグや区分値を定義するenum

今回登場する2つのカラム status と display について、Java側でenumを作成します。statusは「商品の公開範囲を決めるもの」なのでItemPublishと名前を変えておきます。displayは表示する・しないを定める2値ですので、他のカラムでも利用できるよう、汎用的にTrueOrFalseとします。

```java:ItemPublish.java
import java.util.Arrays;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 商品表示区分。
 */
@Getter
@AllArgsConstructor
public enum ItemPublish {
	NONE("0"), PUBLISH("1"), MEMBER_ONLY("2"), NO_VALUE("");

	private String value;

	public static ItemPublish getDisplayStatus(String value) {
		return Arrays.stream(values())
				.filter(v -> v.getValue().equals(value))
				.findFirst()
				.orElse(NO_VALUE);
	}
}
```

区分値がとりうる範囲外の値が指定されたときのために、NO_VALUEを定義[^4]しています。

```TrueOrFalse.java
@Getter
@AllArgsConstructor
public enum TrueOrFalse {
  TRUE("1"), FALSE("0"), NONE("");

  private String value;

  TrueOrFalse(String value) {
    this.value = value;
  }

  public static TrueOrFalse getTrueOrFalse(String input) {
    return Arrays.stream(values())
        .filter(v -> v.getValue().equals(input))
          .findFirst().orElse(NONE);
  }
}
```

真偽値(trueの場合1、falseなら0)を定義するenum。
0と1以外の値は想定外ですが、想定外の値はNONEとして定義します。

## SpringとMybatisの実装

データベースに問い合わせを行う実装です。SpringMVCのRepositoryとServiceです。検索条件は ItemConditionクラス、検索結果の1レコードは Itemクラスで定義します。

```java:ItemRepository.java
mport java.util.List;

import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Repository;

import lombok.RequiredArgsConstructor;
import serendip.spring.sample.mybatis.model.Item;
import serendip.spring.sample.mybatis.model.ItemCondition;

/**
 * 商品検索リポジトリ。
 */
@Repository
@RequiredArgsConstructor
public class ItemRepository {
	private final SqlSession sqlSession;

	public List<Item> selectItems(ItemCondition condition) {
		return sqlSession.selectList("selectItems", condition);
	}
}
```

```java:ItemService.java
import java.util.List;

import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import serendip.spring.sample.mybatis.model.Item;
import serendip.spring.sample.mybatis.model.ItemCondition;
import serendip.spring.sample.mybatis.repository.ItemRepository;

/**
 * 商品検索Service。
 */
@Service
@RequiredArgsConstructor
public class ItemService {
	private final ItemRepository repository;

	public List<Item> selectItems(ItemCondition condition) {
		return repository.selectItems(condition);
	}
}
```

検索条件のItemConditionです。先程定義したenumをそのまま条件に指定しています。

```java:ItemCondition.java
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

/**
 * 商品の検索条件。
 */
@Builder
@ToString
@Getter
public class ItemCondition {
	private int id;
	private ItemPublish status;
	private TrueOrFalse display;
}
```

検索結果の1レコードを表すItemです。カラムstatusとdisplayの値をそれぞれ定義したenumで扱います。

```java:Item.java
import lombok.Getter;
import lombok.Setter;

/**
 * 商品テーブル(Item)の1レコード。
 */
@Getter @Setter
public class Item {
	private Integer id;
	private String name;
	private ItemPublish status;
	private TrueOrFalse display;
}
```

## SQLMappingを定義
今回実行するSQLは次の通りです。

```xml:sql-mappings.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="serendip.spring.sample.mybatis.repository.MemberRepository">
	<select id="selectItems"
		parameterType="serendip.spring.sample.mybatis.model.ItemCondition"
		resultType="serendip.spring.sample.mybatis.model.Item">
		SELECT
			id,
			name,
			status,
			display
		FROM
			item
	</select>
</mapper>
```

以上で実装クラスの準備が整いました。しかしこのまま実行したときMybatisがenumに対して自動的に変換を行うのですが、デフォルトで実行するメソッド[^5]が取得できないため実行時例外がスローされます。

そこで、Mybatisでは特定のenumに対して型変換を実行するTypeHandlerを使います。

## enum用のTypeHandlerを作成
TypeHandlerとは、MybatisがSQLMapping定義に含まれるJavaクラスとデータベース間の変換を行うものです。

### 実装済みのTypeHandler
実装済みのTypeHandlerは、Mybatisの org.apache.ibatis.typeパッケージにて提供されています。ここにJavaの型に対する変換ならびにデータベースのカラムで使う型に対するTypeHandlerを定義しています。例えばJavaのBigDecimalに対する型変換や、データベースのBLOBに対する型変換も提供されており、これらはJDBCのPretaredStatementにある機能を実行します。

### TypeHandlerの拡張
MybatisのTypeHandlerは `BaseTypeHandler<変換対象のクラス>` で行います。SQLへ渡す値を設定する setNonNullParameter と、値を受け取る getNullableResult (メソッドは計3つ）の4メソッドを実装します。

```java:ItemPublishTypeHandler.java
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;

import serendip.spring.sample.mybatis.model.ItemPublish;

/**
 * 商品公開ステータスのEnum用型変換クラス。
 */
public class ItemPublishTypeHandler extends BaseTypeHandler<ItemPublish> {

	@Override
	public void setNonNullParameter(PreparedStatement ps, int i, ItemPublish parameter, JdbcType jdbcType)
			throws SQLException {
		ps.setString(i, parameter.getValue());
	}

	@Override
	public ItemPublish getNullableResult(ResultSet rs, String columnName) throws SQLException {
		return ItemPublish.getDisplayStatus(rs.getString(columnName));
	}

	@Override
	public ItemPublish getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
		return ItemPublish.getDisplayStatus(rs.getString(columnIndex));
	}

	@Override
	public ItemPublish getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
		return ItemPublish.getDisplayStatus(cs.getString(columnIndex));
	}
}
```

```java:TrueOrFalseTypeHandler.java
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;

import serendip.spring.sample.mybatis.model.TrueOrFalse;

/**
 * フラグを扱うEnum用の型変換クラス。
 */
public class TrueOrFalseTypeHandler extends BaseTypeHandler<TrueOrFalse> {

	@Override
	public void setNonNullParameter(PreparedStatement ps, int i, TrueOrFalse parameter, JdbcType jdbcType)
			throws SQLException {
		ps.setString(i, parameter.getValue());
	}

	@Override
	public TrueOrFalse getNullableResult(ResultSet rs, String columnName) throws SQLException {
		return TrueOrFalse.getTrueOrFalse(rs.getString(columnName));
	}

	@Override
	public TrueOrFalse getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
		return TrueOrFalse.getTrueOrFalse(rs.getString(columnIndex));
	}

	@Override
	public TrueOrFalse getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
		return TrueOrFalse.getTrueOrFalse(cs.getString(columnIndex));
	}
}
```

## TypeHandlerの登録
作成したTypeHandlerはMybatisの設定ファイルに記載します。`<typeHandlers>`要素の中に`<typeHandler>`要素にて作成したTypeHandlerを1つずつ記載します。

```xml:mybatis-config.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
	<typeHandlers>
		<typeHandler handler="serendip.spring.sample.mybatis.typehandler.ItemPublishTypeHandler"/>
		<typeHandler handler="serendip.spring.sample.mybatis.typehandler.TrueOrFalseTypeHandler"/>
	</typeHandlers>
...
</configuration>
```

# 検索条件にも適用する

データベースからの検索結果だけでなく、TypeHandlerは引数（パラメータ）にも適用できます。

## SQLMappingでenumを扱う記述
検索条件は前述した ItemCondition の TrueOrFalse を使用します。ごく簡潔に使う場合、enumのgetValue()などで値を取得する方法があります。

```xml:sql-mappings.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="serendip.spring.sample.mybatis.repository.MemberRepository">
	<select id="selectItems"
		parameterType="serendip.spring.sample.mybatis.model.ItemCondition"
		resultType="serendip.spring.sample.mybatis.model.Item">
		SELECT
			id,
			name,
			status,
			display
		FROM
			item
		<where>
			<if test="display.getValue() != ''">
			     display = #{display.value}
			</if>
		</where>
	</select>
</mapper>
```

ただしこの方法は、せっかくenumだけで記述してきた内容を、動的SQL内部で文字列比較していますので、やや勿体ない感じがします。ですが、Mybatisは動的SQLの条件文をOGNLで記載できます。

使ってみましょう。

## SQLMappingでenumを直接比較する

OGNLでenumを参照するには、以下のように `@enumの完全クラス名@列挙子` で表現します。

```xml:sql-mappings.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="serendip.spring.sample.mybatis.repository.MemberRepository">
	<select id="selectItems"
		parameterType="serendip.spring.sample.mybatis.model.ItemCondition"
		resultType="serendip.spring.sample.mybatis.model.Item">
		SELECT
			id,
			name,
			status,
			display
		FROM
			item
		<where>
			<if test="display != @serendip.spring.sample.mybatis.model.TrueOrFalse@NONE">
			    display = #{display.value}
			</if>
		</where>
	</select>
</mapper>
```

これで動的SQLの条件分岐にもenumを適用できました。

# SpringMVCのRestControllerを経由する

今回紹介した内容をSpringのRestControllerから実行し、JSON形式のレスポンスを返すよう作成します。

## Controllerの実装

```java
import java.util.List;
import java.util.Optional;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;
import serendip.spring.sample.mybatis.model.Item;
import serendip.spring.sample.mybatis.model.ItemCondition;
import serendip.spring.sample.mybatis.model.ItemPublish;
import serendip.spring.sample.mybatis.model.TrueOrFalse;
import serendip.spring.sample.mybatis.service.ItemService;

/**
 * 商品検索RestController。
 */
@RestController
@RequestMapping("/items")
@RequiredArgsConstructor
public class ItemController {

	private final ItemService service;

	@GetMapping("")
	public List<Item> searchItems(@RequestParam Optional<String> publish, @RequestParam Optional<String>  display) {
		ItemCondition condition = ItemCondition.builder()
				.status(ItemPublish.getDisplayStatus(publish.orElse("")))
				.display(TrueOrFalse.getTrueOrFalse(display.orElse("")))
				.build();

		return service.selectItems(condition);
	}
}
```

検索条件を設定しない場合も想定して、もしパラメータが未定義だった場合は、それぞれのenum値で未定義のときの値にします。

## 実行結果
http://127.0.0.1:8080/items 

```
[
    {
        "id": 1,
        "name": "木製椅子",
        "status": "NONE",
        "display": "TRUE"
    },
    {
        "id": 2,
        "name": "ガラステーブル",
        "status": "PUBLISH",
        "display": "TRUE"
    },
    {
        "id": 3,
        "name": "木製テーブル",
        "status": "MEMBER_ONLY",
        "display": "FALSE"
    }
]
```

http://127.0.0.1:8080/items?display=0

```
[
    {
        "id": 3,
        "name": "木製テーブル",
        "status": "MEMBER_ONLY",
        "display": "FALSE"
    }
]
```

http://127.0.0.1:8080/items?display=1

```
[
    {
        "id": 1,
        "name": "木製椅子",
        "status": "NONE",
        "display": "TRUE"
    },
    {
        "id": 2,
        "name": "ガラステーブル",
        "status": "PUBLISH",
        "display": "TRUE"
    }
]
```

今回のパラメータ値はデータベースの値をそのまま条件値に使いますが、他の値にすることもControllerでできるでしょう。
また、JSONのレスポンス値をさらに変換したい場合は、enumにjacksonのJsonSerializeインタフェースを実装すると自動的に変換されます。

# おわりに
自動型変換・値変換を使って、より堅牢でメンテナンスしやすいアプリケーション構築の一助になれば幸いです(・ω・）

---
[^1]: 「フラグ」や「区分値」が指し示す内容にも「開発現場やベンダーの方言」があります。この記事では、「フラグ」は2値しか持たない、いわゆるbooleanのtrue/falseのみを示すカラム、「区分値」はboolean以外の目的で、取りうる値の範囲が固定化されているカラムです。
[^2]: 例えば、2018年現在でもあまり好ましくない 削除フラグや更新フラグあたりは、システムの新旧問わず存在するケース。0：有効なレコード、1：削除済み など
[^3]: レガシーシステムでありがちな、名前から内容が推測しにくい、わざとダサダサなカラム名にしています。statusとdisplayって何！？となるでしょうか。
[^4]: 今回は常に何らかの値を返すようにしていますが、ありえない値が指定されたらアプリケーション例外をスローするのもあるでしょう。
[^5]: java.lang.Enum.valueOf メソッドが実行されます。