MyBatisを使用した際につまづいた点とプラグインの一例

SpringBootでMyBatisを使用している際につまづいた点と、その解決策として作成したプラグインの一例を紹介します。

# 1. Selectiveがつくメソッドとつかないメソッドの違い
MyBatisの自動生成で作成したMapperには、insert、update、などのメソッドが用意されています。
これらのメソッドには、Selectiveがつくものとつかないものがあります。
例：「insert」「insertSelective」「updateByPrimaryKey」「updateByPrimaryKeySelective」

これらの違いは、「null」の扱いにあります。

Selectiveが付かないメソッド
- すべてのカラムに対して登録・更新処理を行う
- Entityに何も値を設定しない場合、そのカラムには「null」がセットされる

Selectiveが付くメソッド
- 値がnull以外のカラムに対して登録・更新処理を行う
- Entityに何も値を設定しない場合、登録・更新対象のカラムに含まれない

これらを意識せずに使用していると、想定外のエラーや登録値の原因になるため、注意が必要です。
具体的なシチュエーションと実際に流れるSQLを確認してみます。

なお、本記事でサンプルとして使用しているテーブルレイアウトは以下の通りです。

```sql
CREATE TABLE `sample_table` (
  `id` char(1) NOT NULL,
  `column1` varchar(50),
  `default_check` char(1) NOT NULL default 0,
  `memo1` TEXT ,
  PRIMARY KEY (`id`)
);
```

## 必須カラムがあるテーブルにINSERTする場合

insertメソッドを使用すると、Entityで値を設定していないカラムに対しては、「null」を登録するSQLが実行されます。
たとえテーブル定義でデフォルト値が設定されていたとしても、明示的に「null」を登録しようとするSQLになってしまうため、エラーが発生します。
insertメソッドを使用する場合は、デフォルト値が設定されているカラムに対しても、明示的に値を設定してあげる必要があります。
よくあるのが、既存のテーブルに新規カラムを追加した際、デフォルト値が設定されているからソースは直さなくてもいいだろうと考えてしまうケースです。
上記の通り、insertメソッドを使用する場合は、デフォルト値が設定されているカラムに対しても、値を設定してあげる必要があります。

### NGパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("1");
sampleEntity.setColumn1("A");
// sampleEntity.setDefaultCheck("0");  // 記述しないとエラーになる
sampleEntity.setMemo1("insert");
sampleRepository.insert(sampleEntity);
```

```実際に流れるSQL.sql
insert into sample_table (id, column1, default_check, memo1) values ("1", "A", null, "insert");
```

### OKパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("1");
sampleEntity.setColumn1("A");
sampleEntity.setDefaultCheck("0");  // 記述しないとエラーになる
sampleEntity.setMemo1("insert");
sampleRepository.insert(sampleEntity);
```

```実際に流れるSQL.sql
insert into sample_table (id, column1, default_check, memo1) values ("1", "A", "0", "insert");
```

あるいは、insertSelectiveメソッドを使用します。
insertSelectiveメソッドを使用すると、値を設定していないカラムは登録対象外になるため、テーブル定義としてデフォルト値を設定していれば、そのままデフォルト値が登録されます。

### OKパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("1");
sampleEntity.setColumn1("A");
//sampleEntity.setDefaultCheck("0");  // 記述しなくてもエラーにならない
sampleEntity.setMemo1("insert");
sampleRepository.insertSelective(sampleEntity);
```

```実際に流れるSQL.sql
insert into sample_table (id, column1, memo1) values ("1", "A", "insert");
```

## nullで更新したくない（or 更新したい）カラムがある場合

更新時も考え方は同様です。
updateメソッドを使用すると、Entityで値を設定していないカラムに対しては、「null」で更新するSQLが実行されます。

### NGパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("1");
sampleEntity.setColumn1("B");
// sampleEntity.setDefaultCheck("0"); // 記述しないとエラーになる
sampleEntity.setMemo1("update");
sampleRepository.updateByPrimaryKey(sampleEntity);
```

```実際に流れるSQL.sql
update sample_table set column1 = "B", default_check = null, memo1 = "update" where id = "1";
```
### OKパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("1");
sampleEntity.setColumn1("B");
sampleEntity.setDefaultCheck("0"); // 記述しないとエラーになる
sampleEntity.setMemo1("update");
sampleRepository.updateByPrimaryKey(sampleEntity);
```

```実際に流れるSQL.sql
update sample_table set column1 = "B", default_check = "0", memo1 = "update" where id = "1";
```

updateSelectiveであれば、値を設定していないカラムは更新対象外になります。

### OKパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("1");
sampleEntity.setColumn1("B");
//sampleEntity.setDefaultCheck("0"); // 記述しなくてもエラーにならない
sampleEntity.setMemo1("update");
sampleRepository.updateByPrimaryKeySelective(sampleEntity);
```
```実際に流れるSQL.sql
update sample_table set column1 = "B", memo1 = "update" where id = "1";
```

また、updateSelectiveを使用する場合は、nullで更新することはできないため注意が必要です。
nullで更新したいカラムがある場合は、updateを使用する必要があります。
### NGパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("1");
sampleEntity.setColumn1("B");
sampleEntity.setDefaultCheck("0");
sampleEntity.setMemo1(null); // nullで更新できない
sampleRepository.updateByPrimaryKeySelective(sampleEntity);
```

```実際に流れるSQL.sql
update sample_table set column1 = "B", default_check = "0" where id = "1";
```
### OKパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("8");
sampleEntity.setColumn1("B");
sampleEntity.setDefaultCheck("0");
sampleEntity.setMemo1(null);
sampleRepository.updateByPrimaryKey(sampleEntity);
```
    
```実際に流れるSQL.sql
update sample_table set column1 = "B", default_check = "0", memo1 = null where id = "8";
```

ただし、updateを使用する場合は、明示的にnullを設定していなくても、何も値を設定しない場合は、「null」として更新されることにも注意が必要です。

### OKパターン
```sample.java
SampleTableEntity sampleEntity = new SampleTableEntity();
sampleEntity.setId("8");
sampleEntity.setColumn1("B");
sampleEntity.setDefaultCheck("0");
// sampleEntity.setMemo1(null); // 記述しなくてもnullで更新される
sampleRepository.updateByPrimaryKey(sampleEntity);
```
```実際に流れるSQL.sql
update sample_table set column1 = "B", default_check = "0", memo1 = null where id = "8";
```


# 2. DBの型が「TEXT」「BLOB」の場合に、Selectメソッドが分離される
つまづいた点の2つ目として、DBの型が「TEXT」「BLOB」の場合に、SelectByExampleメソッドが分離されることがあります。
型が「TEXT」のカラムは通常のselectByExamplメソッドで取得できず、selectByExampleWithBLOBsメソッドを使用する必要がありました。
Mapper.xmlは以下のようになっています。

```自動生成されたMapper.xml
  <sql id="Base_Column_List">
    id, column1, default_check
  </sql>
  <sql id="Blob_Column_List">
    <!-- TEXT型のカラムだけ分離されている -->
    memo1
  </sql>
    <!-- こちらのメソッドを使用しないと、memo1カラムは取得できない -->
    <select id="selectByExampleWithBLOBs" parameterType="jp.co.demo.sample.entity.generated.SampleTableExample" resultMap="ResultMapWithBLOBs">
    select
    <if test="distinct">
      distinct
    </if>
    <include refid="Base_Column_List" />
    ,
    <include refid="Blob_Column_List" />
    from sample_table
    <if test="_parameter != null">
      <include refid="Example_Where_Clause" />
    </if>
    <if test="orderByClause != null">
      order by ${orderByClause}
    </if>
  </select>
  <select id="selectByExample" parameterType="jp.co.demo.sample.entity.generated.SampleTableExample" resultMap="BaseResultMap">
    select
    <if test="distinct">
      distinct
    </if>
    <include refid="Base_Column_List" />
    from sample_table
    <if test="_parameter != null">
      <include refid="Example_Where_Clause" />
    </if>
    <if test="orderByClause != null">
      order by ${orderByClause}
    </if>
  </select>
```

 targetRuntimeに「MyBatis3」を使用しているためなのか、回避策として見かけたmodelTypeを「flat」にするという方法でも回避できなかったため、力技でプラグインを作成して解決しました。
 以下、プラグインの書き方の一例です。
 BLOB型のカラムをBase_Column_Listに追加し、selectByExampleメソッドで取得できるようにしています。
 また、BLOB型のカラムに対して、条件を指定するメソッドを追加しています。
 
```MyBatisPlugin.java
	@Override
	public boolean modelBaseRecordClassGenerated(TopLevelClass topLevelClass, IntrospectedTable introspectedTable) {
		List<IntrospectedColumn> blobColumns = introspectedTable.getBLOBColumns();
		for (IntrospectedColumn column : blobColumns) {
			introspectedTable.getBaseColumns().add(column);
		}
		introspectedTable.getBLOBColumns().clear();
		return super.modelBaseRecordClassGenerated(topLevelClass, introspectedTable);
	}

	@Override
	public boolean modelExampleClassGenerated(TopLevelClass topLevelClass, IntrospectedTable introspectedTable) {
		for (IntrospectedColumn column : introspectedTable.getAllColumns()) {
			if (column.isBLOBColumn()) {
				addTextColumnMethods(topLevelClass, column);
			}
		}
		return true;
	}

	private void addTextColumnMethods(TopLevelClass topLevelClass, IntrospectedColumn column) {
		// GeneratedCriteriaクラスを探す
		InnerClass generatedCriteriaClass = null;
		for (InnerClass innerClass : topLevelClass.getInnerClasses()) {
			if (innerClass.getType().getShortName().equals("GeneratedCriteria")) {
				generatedCriteriaClass = innerClass;
				break;
			}
		}

		if (generatedCriteriaClass == null) {
			return;
		}

		String camelCaseName = column.getJavaProperty();
		String capitalizedName = camelCaseName.substring(0, 1).toUpperCase() + camelCaseName.substring(1);
		String columnName = column.getActualColumnName();

		addMethod(generatedCriteriaClass, "and" + capitalizedName + "IsNull", columnName + " is null");
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "IsNotNull", columnName + " is not null");
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "EqualTo", columnName + " =",
				column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "NotEqualTo", columnName + " <>",
				column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "GreaterThan", columnName + " >",
				column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "GreaterThanOrEqualTo", columnName + " >=",
				column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "LessThan", columnName + " <",
				column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "LessThanOrEqualTo", columnName + " <=",
				column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "Like", columnName + " like",
				column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "NotLike", columnName + " not like",
				column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "In", columnName + " in",
				new FullyQualifiedJavaType(
						"java.util.List<" + column.getFullyQualifiedJavaType().getShortName() + ">"));
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "NotIn", columnName + " not in",
				new FullyQualifiedJavaType(
						"java.util.List<" + column.getFullyQualifiedJavaType().getShortName() + ">"));
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "Between", columnName + " between",
				column.getFullyQualifiedJavaType(), column.getFullyQualifiedJavaType());
		addMethod(generatedCriteriaClass, "and" + capitalizedName + "NotBetween", columnName + " not between",
				column.getFullyQualifiedJavaType(), column.getFullyQualifiedJavaType());
	}

	private void addMethod(InnerClass innerClass, String methodName, String condition) {
		Method method = new Method(methodName);
		method.setVisibility(org.mybatis.generator.api.dom.java.JavaVisibility.PUBLIC);
		method.setReturnType(new FullyQualifiedJavaType("Criteria"));
		method.addBodyLine("addCriterion(\"" + condition + "\");");
		method.addBodyLine("return (Criteria) this;");
		innerClass.addMethod(method);
	}

	private void addMethod(InnerClass innerClass, String methodName, String condition,
			FullyQualifiedJavaType paramType) {
		Method method = new Method(methodName);
		method.setVisibility(org.mybatis.generator.api.dom.java.JavaVisibility.PUBLIC);
		method.setReturnType(new FullyQualifiedJavaType("Criteria"));
		method.addParameter(new org.mybatis.generator.api.dom.java.Parameter(paramType, "value"));
		method.addBodyLine("addCriterion(\"" + condition + "\", value, \"" + paramType.getShortName() + "\");");
		method.addBodyLine("return (Criteria) this;");
		innerClass.addMethod(method);
	}

	private void addMethod(InnerClass innerClass, String methodName, String condition,
			FullyQualifiedJavaType paramType1, FullyQualifiedJavaType paramType2) {
		Method method = new Method(methodName);
		method.setVisibility(org.mybatis.generator.api.dom.java.JavaVisibility.PUBLIC);
		method.setReturnType(new FullyQualifiedJavaType("Criteria"));
		method.addParameter(new org.mybatis.generator.api.dom.java.Parameter(paramType1, "value1"));
		method.addParameter(new org.mybatis.generator.api.dom.java.Parameter(paramType2, "value2"));
		method.addBodyLine(
				"addCriterion(\"" + condition + "\", value1, value2, \"" + paramType1.getShortName() + "\");");
		method.addBodyLine("return (Criteria) this;");
		innerClass.addMethod(method);
	}
```

プラグインを適用して生成されたMapper.xmlは次のようになりました。
```自動生成されたMapper.xml
  <sql id="Base_Column_List">
    <!-- Base_Column_ListにTEXT型の「memo1」も含まれている -->
    id, column1, default_check, memo1
  </sql>
```

createCriteriaで条件を指定することも可能になります。
```Sample.java
SampleTableExample sampleExample = new SampleTableExample();
// TEXT型のカラムもcreateCriteriaで条件指定が可能
sampleExample.createCriteria().andMemo1Like("%Hoge%");
List<SampleTableEntity> sampleEntityList = sampleRepository.selectByExample(sampleExample);
  ```