MyBatis/iBatisの同値検証、sqlmapファイル（動的SQL）自体を単体テストする方法

## 1． はじめに
MyBatisのmapperファイル、iBatisのsqlmapファイルにはパラメータの値によって出力するSQLを変更する動的SQL機能があり、プログミングにおける条件分岐と同様と考えることができます。[別の記事](https://qiita.com/5zm/items/0c08b8aa7303789a6ed4)で書きましたが、動的SQLの評価箇所を集中的に試験・確認したい状況になりました。今回はその方法（同値検証ツール）について記載したいと思います。
同じ内容のiBatis、MyBatisのsalmapファイルを実装した場合を想定し、同じパラメータを入力して出力されるSQLを比較・検証するというものです。動的SQLを全て評価するようにパラメータを変更して複数回実行します。実行結果を見るとイメージが掴めるかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195748/46df2da6-0e76-4b0e-17fd-f12029eea9b3.png)


```xml:ibatis-BlogMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE sqlMap
  PUBLIC "-//ibatis.apache.org//DTD SQL Map 2.0//EN"
  "http://ibatis.apache.org/dtd/sql-map-2.dtd">
<sqlMap namespace="org.mybatis.example.BlogMapper">
  <select id="findActiveBlogLike" parameterClass="java.util.Map" resultClass="java.util.Map">
    SELECT * FROM BLOG WHERE state = 'ACTIVE'
    <isNotNull prepend="AND" property="title">
      title like #title#
    </isNotNull>
    <isNotNull prepend="AND" property="author_name">
      author_name like #author_name#
    </isNotNull>
  </select>
</sqlMap>
```

```xml:BlogMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.mybatis.example.BlogMapper">
  <select id="findActiveBlogLike" resultType="java.util.Map">
    SELECT * FROM BLOG WHERE state = 'ACTIVE'
    <if test="title != null">
      AND title like #{title}
    </if>
    <if test="author_name != null">
      AND author_name like #{author_name}
    </if>
  </select>
</mapper>
```

```console:実行結果
------------------------------------------------
[param] {}
[expect]  SELECT * FROM BLOG WHERE STATE = 'ACTIVE'
[mybatis] SELECT * FROM BLOG WHERE STATE = 'ACTIVE'
[ibatis]  SELECT * FROM BLOG WHERE STATE = 'ACTIVE'
[mybatis:assertEquals] true
[ibatis:assertEquals]  true
[mybatis = ibatis]  true
------------------------------------------------
[param] {title=helloword}
[expect]  SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND TITLE LIKE ?
[mybatis] SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND TITLE LIKE ?
[ibatis]  SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND TITLE LIKE ?
[mybatis:assertEquals] true
[ibatis:assertEquals]  true
[mybatis = ibatis]  true
------------------------------------------------
[param] {author_name=JIRO IIDABASHI, title=helloword}
[expect]  SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND TITLE LIKE ? AND AUTHOR_NAME LIKE ?
[mybatis] SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND TITLE LIKE ? AND AUTHOR_NAME LIKE ?
[ibatis]  SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND TITLE LIKE ? AND AUTHOR_NAME LIKE ?
[mybatis:assertEquals] true
[ibatis:assertEquals]  true
[mybatis = ibatis]  true
------------------------------------------------
[param] {author_name=JIRO IIDABASHI}
[expect]  SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND AUTHOR_NAME LIKE ?
[mybatis] SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND AUTHOR_NAME LIKE ?
[ibatis]  SELECT * FROM BLOG WHERE STATE = 'ACTIVE' AND AUTHOR_NAME LIKE ?
[mybatis:assertEquals] true
[ibatis:assertEquals]  true
[mybatis = ibatis]  true
```

## ２． ポイント
* iBatis,MyBatisが実際に行なっているSQL生成処理を呼び出して利用する
* 動的SQLの評価を行った結果のSQLを取得するため、sqlmapファイルとしての動作を確認することができる
* DBアクセスは行わないため動作時にデータベースを用意する必要はない
* 必要最小限のライブラリで動作するため、軽快に動作させることができる
    * MyBatisだけであればMyBatisのライブラリのみ
    * iBatisを対象とする場合はiBatisとJDBCドライバの２つのライブラリを追加
* 今回は説明用・軽量化のため、mainメソッドのコンソールアプリとしている
    * 注意　：　mavenプロジェクトの生成後、修正漏れのためJava8で実装・動作確認

## ３． ソースコード

```java:App.java
package com.example.mybatis.demo;

import java.io.Reader;
import java.io.InputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
// MyBatis
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
// iBatis
import com.ibatis.sqlmap.client.SqlMapClient;
import com.ibatis.sqlmap.client.SqlMapClientBuilder;
import com.ibatis.sqlmap.client.SqlMapSession;
import com.ibatis.sqlmap.engine.impl.SqlMapClientImpl;
import com.ibatis.sqlmap.engine.impl.SqlMapSessionImpl;
import com.ibatis.sqlmap.engine.mapping.sql.Sql;
import com.ibatis.sqlmap.engine.scope.SessionScope;
import com.ibatis.sqlmap.engine.scope.StatementScope;

public class App {
    // ⭐️　ポイント1
    private static SqlSessionFactory getSqlSessionFactory() throws IOException {
        String resource = "dummy-mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        return sqlSessionFactory;
    }

    // ⭐️　ポイント2
    private static String evaluateDynamicSql(SqlSession session, String sqlid, Object param) {
        MappedStatement mappedStatement = session.getConfiguration().getMappedStatement(sqlid);
        BoundSql boundSql = mappedStatement.getBoundSql(param);
        String sql = boundSql.getSql();
        return sql;
    }

    // ⭐️　ポイント3
    private static String trimWhiteSpaceWithUpperCase(String text) {
        if (text == null) {
            return "";
        }
        String trimCRLF = text.replaceAll("\\v", " ");
        trimCRLF = trimCRLF.replaceAll("\\h{2,}", " ");
        while (trimCRLF.indexOf(" ") == 0) {
            trimCRLF = trimCRLF.replaceFirst(" ", "");
        }
        while (trimCRLF.lastIndexOf(" ") == trimCRLF.length() -1) {
            trimCRLF = trimCRLF.substring(0, trimCRLF.length() -1);
        }
        return trimCRLF.toUpperCase();
    }

    // ⭐️　ポイント4
    private static final class SqlMapSessionImplDummy extends SqlMapSessionImpl {
        public SqlMapSessionImplDummy(SqlMapClientImpl Client) {
            super(Client);
        }
        
        public SessionScope captureSessionScope() {
            return super.sessionScope;
        }
    }

    // ⭐️　ポイント5
    private static SqlMapClient getSqlMapClient() throws java.io.IOException {
        Reader reader = com.ibatis.common.resources.Resources.getResourceAsReader("dummy-ibatis-sqlmap-config.xml");
        SqlMapClient sqlMapClient = SqlMapClientBuilder.buildSqlMapClient(reader);
        return sqlMapClient;
    }

    // ⭐️　ポイント6
    private static String evaluateDynamicSql(SqlMapClient sqlMapClient, String sqlid, Object param) {
        SqlMapClientImpl sqlMapClientImpl = (SqlMapClientImpl) sqlMapClient;
        SqlMapSessionImplDummy sqlMapSessionImplDummy = new SqlMapSessionImplDummy(sqlMapClientImpl);

        SessionScope sessionScope = sqlMapSessionImplDummy.captureSessionScope();
        StatementScope statementScope = new StatementScope(sessionScope);
        sessionScope.incrementRequestStackDepth();
        
        com.ibatis.sqlmap.engine.mapping.statement.MappedStatement mappedStatement = sqlMapSessionImplDummy.getMappedStatement(sqlid);
        mappedStatement.initRequest(statementScope);

        Sql sql = mappedStatement.getSql();
        String sqlText = sql.getSql(statementScope, param);
        return sqlText;
    }

    public static void main(String[] args) {
        try {
            // ⭐️　ポイント7
            SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
            SqlMapClient sqlMapClient = getSqlMapClient();
            SqlMapSession sqlMapSession = sqlMapClient.openSession();
            try (SqlSession session = sqlSessionFactory.openSession()) {
                // ⭐️　ポイント8
                String sqlid = "org.mybatis.example.BlogMapper.findActiveBlogLike";
                Map<String, String> param = new HashMap<>();

                System.out.println("------------------------------------------------");
                String expect01 = "SELECT * FROM BLOG WHERE state = 'ACTIVE'";
                String sql01m = evaluateDynamicSql(session, sqlid, param);
                String sql01i = evaluateDynamicSql(sqlMapClient, sqlid, param);
                System.out.println("[param] " + param);
                System.out.println("[expect]  " + trimWhiteSpaceWithUpperCase(expect01));
                System.out.println("[mybatis] " + trimWhiteSpaceWithUpperCase(sql01m));
                System.out.println("[ibatis]  " + trimWhiteSpaceWithUpperCase(sql01i));
                System.out.println("[mybatis:assertEquals] " + trimWhiteSpaceWithUpperCase(expect01).equals(trimWhiteSpaceWithUpperCase(sql01m)));
                System.out.println("[ibatis:assertEquals]  " + trimWhiteSpaceWithUpperCase(expect01).equals(trimWhiteSpaceWithUpperCase(sql01i))); 
                System.out.println("[mybatis = ibatis]  " + trimWhiteSpaceWithUpperCase(sql01m).equals(trimWhiteSpaceWithUpperCase(sql01i))); 

                System.out.println("------------------------------------------------");
                param.put("title", "helloword");
                String expect02 = "SELECT * FROM BLOG WHERE      state  = 'ACTIVE'"
                                + " AND title         like ?";
                String sql02m = evaluateDynamicSql(session, sqlid, param);
                String sql02i = evaluateDynamicSql(sqlMapClient, sqlid, param);
                System.out.println("[param] " + param);
                System.out.println("[expect]  " + trimWhiteSpaceWithUpperCase(expect02));
                System.out.println("[mybatis] " + trimWhiteSpaceWithUpperCase(sql02m));
                System.out.println("[ibatis]  " + trimWhiteSpaceWithUpperCase(sql02i));
                System.out.println("[mybatis:assertEquals] " + trimWhiteSpaceWithUpperCase(expect02).equals(trimWhiteSpaceWithUpperCase(sql02m)));
                System.out.println("[ibatis:assertEquals]  " + trimWhiteSpaceWithUpperCase(expect02).equals(trimWhiteSpaceWithUpperCase(sql02i)));
                System.out.println("[mybatis = ibatis]  " + trimWhiteSpaceWithUpperCase(sql02m).equals(trimWhiteSpaceWithUpperCase(sql02i))); 

                System.out.println("------------------------------------------------");
                param.put("author_name", "JIRO IIDABASHI");
                String expect03 = "SELECT * FROM BLOG WHERE state = 'ACTIVE'"
                                + "   AND      title     like ?"
                                + "   AND author_name    like   ?";
                String sql03m = evaluateDynamicSql(session, sqlid, param);
                String sql03i = evaluateDynamicSql(sqlMapClient, sqlid, param);
                System.out.println("[param] " + param);
                System.out.println("[expect]  " + trimWhiteSpaceWithUpperCase(expect03));
                System.out.println("[mybatis] " + trimWhiteSpaceWithUpperCase(sql03m));
                System.out.println("[ibatis]  " + trimWhiteSpaceWithUpperCase(sql03i));
                System.out.println("[mybatis:assertEquals] " + trimWhiteSpaceWithUpperCase(expect03).equals(trimWhiteSpaceWithUpperCase(sql03m)));
                System.out.println("[ibatis:assertEquals]  " + trimWhiteSpaceWithUpperCase(expect03).equals(trimWhiteSpaceWithUpperCase(sql03i)));
                System.out.println("[mybatis = ibatis]  " + trimWhiteSpaceWithUpperCase(sql03m).equals(trimWhiteSpaceWithUpperCase(sql03i))); 

                System.out.println("------------------------------------------------");
                param.remove("title");
                String expect04 = "SELECT * FROM BLOG WHERE state = 'ACTIVE'"
                                + "          AND author_name like ?";
                String sql04m = evaluateDynamicSql(session, sqlid, param);
                String sql04i = evaluateDynamicSql(sqlMapClient, sqlid, param);
                System.out.println("[param] " + param);
                System.out.println("[expect]  " + trimWhiteSpaceWithUpperCase(expect04));
                System.out.println("[mybatis] " + trimWhiteSpaceWithUpperCase(sql04m));
                System.out.println("[ibatis]  " + trimWhiteSpaceWithUpperCase(sql04i));
                System.out.println("[mybatis:assertEquals] " + trimWhiteSpaceWithUpperCase(expect04).equals(trimWhiteSpaceWithUpperCase(sql04m)));
                System.out.println("[ibatis:assertEquals]  " + trimWhiteSpaceWithUpperCase(expect04).equals(trimWhiteSpaceWithUpperCase(sql04i)));
                System.out.println("[mybatis = ibatis]  " + trimWhiteSpaceWithUpperCase(sql04m).equals(trimWhiteSpaceWithUpperCase(sql04i))); 
            }
            sqlMapSession.close();
        } catch(Exception e) {
            e.printStackTrace();
        }
    }
}
```
⭐️　ポイント1
MyBatisの一般的なお作法に則り`SqlSessionFactory`のインスタンスを生成します。詳細は後述しますが、データベースにはアクセスしないのでコンフィグファイルの記載には少しだけ工夫が必要です。

⭐️　ポイント２
MyBatis版の一番の重要ポイントです。動的SQLを評価した結果のSQLは`BoundSql`から取得することができます。そこまで辿り着くために`SqlSession`からsqlidに紐づく`MappedStatement`を取得し、`MappedStatement`からパラメータを指定して`BoundSql`を取得するという流れになります。

⭐️　ポイント３
MyBatis、iBatisが生成するSQLはsqlmapファイルに記載されたテキストをもとにしているため、sqlmapファイルの改行や空白文字が含まれます。このままでは期待値との比較等が難しいので、単純なテキスト処理としてトリム処理を実装してしています。実際に出力されるSQLがどのようなものか興味のある方は、このメソッドを呼び出さないで確認してみてください。

⭐️　ポイント４
iBatisのためのインナークラスです。出力されるSQLを取得する処理はMyBatisの方はAPIだけで可能ですが、iBatisの方は内部実装を意識して下準備が必要になります。現在の処理で利用している`SessionScope`インスタンスが必要で、それにアクセスして取得できるように`SqlMapSessionImpl`を継承したクラスを実装しています。

⭐️　ポイント５
ポイント１と同様、iBatisの一般的なお作法に則り`SqlMapClient`のインスタンスを生成します。iBatisとMyBatisではパッケージは異なりますがクラス名は同じものが多々あります。同名のクラスは`import`できないので`FQCN`でクラスを指定する等の制約が発生するので注意してください。

⭐️　ポイント６
iBatis版の一番の重要ポイントです。ポイント５で生成した`SqlMapClient`はインターフェースで実体は`SqlMapClientImpl`クラスのインスタンスになります。ポイント４で定義したインナークラスのインスタンスを生成するにはこの`SqlMapClientImpl`インスタンスが必要になります。

現在の処理に紐づく`SessionScope`のインスタンスを取得し、それを用いて`StatementScope`インスタンスを生成します。`incrementRequestStackDepth`メソッドを呼び出して`StatementScope`の準備は完了です。

続いてsqlidに紐づく`MappedStatement`を取得します。その後、`initRequest`メソッドで用意した`StatementScope`を指定してDBアクセスのリクエスト準備を行います。

iBatisの場合は`MappedStatement`の後に`Sql`という１階層がありますが、`getSql`メソッドで準備した`StatementScope`インスタンスとパラメータを指定して、ようやく動的SQLを評価した結果のSQLが取得できます。

⭐️　ポイント７
準備した`getSqlSessionFactory`メソッド、`getSqlMapClient`メソッドを利用して必要なインスタンスを生成します。その後、それぞれ`session`をオープンします。なお、iBatisは古いのでtry-with-resourcesに対応していないようです。

⭐️　ポイント８
iBatis,MyBatisで呼び出すsqlidとその際に引き渡すパラメータです。今回はsqlmapファイルの記載に合わせてMap型としています。POJOを利用している場合はそれに合わせて準備してください。

```xml:dummy-mybatis-config.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
  <environments default="development">
    <environment id="development">
      <transactionManager type="JDBC"/>
      <dataSource type="POOLED">
        <property name="driver" value="org.postgresql.Driver"/>
        <property name="url" value="localhost:5432"/>
        <property name="username" value="dummy"/>
        <property name="password" value="dummy"/>
      </dataSource>
    </environment>
  </environments>
  <mappers>
    <mapper resource="BlogMapper.xml"/>
  </mappers>
</configuration>
```

実際にデータベースにアクセスすることありませんが`dataSource`の定義は必要です。JDBCドライバは存在する値を設定しますが、URLやID/PWDはダミーの値で構いません。`<mapper>`に今回確認したいmapperファイルを記載します。クラス名を短縮するAlias等、mapperファイルを正しく動作させるために必要な設定があれば設定を追加してください。

```xml:dummy-ibatis-sqlmap-config.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE sqlMapConfig
PUBLIC "-//ibatis.apache.org//DTD SQL Map Config 2.0//EN"
"http://ibatis.apache.org/dtd/sql-map-config-2.dtd">

<sqlMapConfig>
    <settings
        useStatementNamespaces="true"
    />
    <transactionManager type="JDBC">
        <dataSource type="SIMPLE">
            <property name="JDBC.Driver" value="org.postgresql.Driver" />
            <property name="JDBC.ConnectionURL" value="localhost:5432" />
            <property name="JDBC.Username" value="dummy" />
            <property name="JDBC.Password" value="dummy" />
        </dataSource>
    </transactionManager>
    <sqlMap resource="ibatis-BlogMapper.xml" />
</sqlMapConfig>
```
注意点はMyBatisの設定ファイルと同様です。

## ４． 補足
```xml:pom.xml
<!-- mybatis -->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.13</version>
</dependency>
<!-- ibatis -->
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis2</artifactId>
    <version>2.3.7</version>
</dependency>
<!-- postgresql -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.6.0</version>
</dependency>
```

```console:関連コマンド
# プロジェクトを生成するmavenコマンド
mvn archetype:generate ^
-DinteractiveMode=false ^
-DarchetypeArtifactId=maven-archetype-quickstart ^
-DgroupId=com.example.mybatis.demo ^
-DartifactId=mybatis-demo

# お試しビルドするmavenコマンド
mvn package -Dmaven.test.skip=true

# 依存ライブラリをダウンロードするmavenコマンド
mvn dependency:copy-dependencies -DoutputDirectory=lib

# 今回のアプリを実行するコマンド　（MyBatis,iBatis両方の記載がある場合）
java -cp .\lib\mybatis-3.5.13.jar;.\lib\mybatis2-2.3.7.jar;.\lib\postgresql-42.6.0.jar;.\target\mybatis-demo-1.0-SNAPSHOT.jar com.example.mybatis.demo.App

# 今回のアプリを実行するコマンド　（iBatisの記載はなくMyBatisのみの場合）
java -cp .\lib\mybatis-3.5.13.jar;.\target\mybatis-demo-1.0-SNAPSHOT.jar com.example.mybatis.demo.App
```

## ５． さいごに
実際のシステム開発では多くの動的SQLが利用されていたり、タグの入れ子が深いsqlmapファイルもかなり存在します。またsqlmapファイル自体がそもそも数千〜数万もあったりします。WebやBatch等のアプリケーションを起動させてsalmapファイルの動的SQLを評価するにはリソース負荷が大きい（動作に時間が掛かる、処理が重い等）ため、sqlmapファイルを単体で動作・検証する仕組みを今回検討しました。またiBatis,MyBatisという新旧ライブラリを同時に実行させるのは結構大変ということも久々に実感しました。今後は[別の記事](https://qiita.com/5zm/items/0c08b8aa7303789a6ed4)で記載したsqlmapファイルの解析データと今回の記事の内容を連携した同値検証の効率化を検討する予定です。