【MyBatis】Formクラスを使った検索処理で動的SQLがうまくいかない【SpringBoot】

### はじめに
最近C#での開発をしておりましたが、久しぶりにJava,MyBatisを使いました。
使いやすいななんて思っていたら詰まってしまい、解決に時間がかかってしまいました。
今回は詰まった時の導き方も合わせてまとめます。

#### バージョン
Spring Boot 2.7.3
MyBatis 2.2.2
Thymeleaf 3.0.15

### 状態
社員一覧画面にてテキストボックス（氏名）とプルダウン（権限）の値をもとに検索する処理で、全件検索されてしまっている。
![スクリーンショット 2023-04-01 23.23.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3097017/b7ef80fb-cfd5-178b-d4ed-275db4f0bedd.png)
ControllerのデバッグでFormクラスに値が入っていることは確認。
![スクリーンショット 2023-04-01 23.21.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3097017/29e4c34b-5e04-6261-c315-64ed332c12fd.png)

#### Before
正常に検索できないBeforeコードを以下に載せます。
<details><summary>長いため開いてご覧ください。</summary><div>

```UsersController.java
// 抜粋
@Controller
public class UsersController {
    @Autowired
    UsersMapper usersMapper;

    /**
     * 検索用Formオブジェクトを初期化して返却する
     * @return 検索用Formオブジェクト
     */
    @ModelAttribute("searchUsersForm")
    public SearchUsersForm createSearchForm(){
        SearchUsersForm searchUsersForm = new SearchUsersForm();
        return searchUsersForm;
    }

    @RequestMapping("/user/list")
    public String index(Model model) {
        List<Users> userList = usersMapper.findAll();
        model.addAttribute("userList", userList);
        model.addAttribute("roleClassList", RoleClass.values());
        return "user/list";
    }

    @RequestMapping("/user/search")
    public String search(SearchUsersForm searchUsersForm, Model model) {
        List<Users> resultList = usersMapper.findBySearchForm(searchUsersForm);
        model.addAttribute("userList", resultList);
        model.addAttribute("roleClassList", RoleClass.values());
        return "user/list";
    }
}
```
```SearchUsersForm.java
// 抜粋
@Data
// 社員検索
public class SearchUsersForm {

    // 氏名
    private String userName;

    // 検索条件
    private String searchCondition;

    // 権限区分
    private String roleClass;

}
```
```UsersMapper.java
// 抜粋
@Mapper
public interface UsersMapper {
    // 検索
    public List<Users> findBySearchForm(SearchUsersForm searchUsersForm);
}
```
```UsersMapper.xml
// 抜粋
<mapper namespace="com.mailnaxx.mapper.UsersMapper">
    <select id="findBySearchForm" parameterType="com.mailnaxx.form.SearchUsersForm" resultType="com.mailnaxx.entity.Users">
        SELECT * FROM users
        WHERE deleted_flg = '0'
        <if test="userName != null and userName != '' and searchCondition == '0'" >
            AND user_name LIKE #{userName} || '%'
        </if>
        <if test="userName != null and userName != '' and searchCondition == '1'" >
            AND user_name LIKE '%' || #{userName} || '%'
        </if>
        <if test="roleClass != null and roleClass != '' ">
            AND role_class = #{roleClass}
        </if>
    </select>
</mapper>
```
```list.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="common :: meta_header('社員一覧',~{::link},~{::script})">
<link rel="stylesheet" th:href="@{/css/user/list.css}" />
</head>
<body>
    <div th:replace="common :: header"></div>

    <div class="contents">
        <div class="container">
            <div class="searchArea mx-auto my-5">
                <form method="post" th:action="@{/user/search}" th:object="${searchUsersForm}">
                    <div class="row mb-3 align-items-center">
                        <div class="col-md-2 text-md-end">
                            <label for="userName" class="col-form-label fw-bold">氏名</label>
                        </div>

                        <div class="col-md-6">
                            <input type="text" class="form-control" placeholder="" name="userName" id="userName" th:value="*{userName}" th:field="*{userName}" />
                        </div>
                        <div class="col-md-4 d-flex justify-content-start align-items-start flex-md-column flex-lg-row">
                            <div class="form-check ms-3">
                                <input class="form-check-input" type="radio" name="searchCondition" id="prefixMatch" th:value="0" th:field="*{searchCondition}" checked>
                                <label class="form-check-label" for="prefixMatch">
                                    前方一致
                                </label>
                            </div>
                            <div class="form-check ms-3">
                                <input class="form-check-input" type="radio" name="searchCondition" id="partialMatch" th:value="1" th:field="*{searchCondition}">
                                <label class="form-check-label" for="partialMatch">
                                    部分一致
                                </label>
                            </div>
                        </div>
                    </div>
                    <div class="row mb-3">
                        <div class="col-md-2 text-md-end">
                            <label for="roleClass" class="col-form-label fw-bold ms-1">権限区分</label>
                        </div>
                        <div class="col-md-6 d-flex align-items-md-center">
                            <select class="form-select" name="roleClass" id="roleClass">
                                <option th:each="roleClass : ${roleClassList}"
                                        th:value="${roleClass.value}"
                                        th:text="${roleClass.viewName}"
                                        th:field="*{roleClass}">
                                </option>
                            </select>
                        </div>
                    </div>
                    <div class="text-center">
                        <button type="submit" class="btn btnMain">検索</button>
                    </div>
                </form>
            </div>

            <div class="listArea mx-auto">
                <form method="post" th:action="@{/delete}">
                    <div class="btn-toolbar justify-content-between" role="toolbar">
                        <div class="mb-4">
                            <button type="button" class="btn btnLight" onclick="location.href='/user/register'">新規登録</button>
                        </div>
                        <div class="mb-4" role="group">
                            <button type="button" class="btn btnAccent me-3">報告を見る</button>
                            <input type="submit" class="btn btnDark" value="削除">
                        </div>
                    </div>

                    <div class="table-responsive">
                        <table class="table table-striped table-hover">
                            <thead>
                                <tr class="border-bottom border-dark">
                                    <th scope="col"><!-- 選択 --></th>
                                    <th scope="col">社員番号</th>
                                    <th scope="col">氏名</th>
                                    <th scope="col">入社年月</th>
                                    <th scope="col">所属</th>
                                    <th scope="col">権限区分</th>
                                    <th scope="col"><!-- 営業 --></th>
                                </tr>
                            </thead>
                            <tbody>
                            <th:block th:each="userList : ${userList}">
                                <!-- <tr th:href="@{'/user-detail/' + ${userList.user_id}}"> -->
                                <tr>
                                    <td>
                                        <div class="form-check d-flex justify-content-center align-items-center">
                                            <input class="form-check-input" type="checkbox" name="deleted_flg" value="1">
                                            <input type="hidden" name="user_id" th:value="${userList.user_id}">
                                        </div>
                                    </td>
                                    <td>
                                        <a th:href="@{'/user/detail/' + ${userList.user_id}}">
                                            <span th:text="${userList.user_number}"></span>
                                        </a>
                                    </td>
                                    <td th:text="${userList.user_name}"></td>
                                    <td th:text="${#strings.replace(userList.hire_date, '-', '/')}"></td>
                                    <td th:text="${userList.affiliation_id}"></td>
                                    <th:block th:each="roleClass : ${roleClassList}">
                                        <th:block th:if="${userList.role_class == roleClass.value}">
                                            <td th:text="${roleClass.viewName}"></td>
                                        </th:block>
                                    </th:block>
                                    <td th:text="${userList.sales_flg == '1'} ? '営業' : ' '"></td>
                                </tr>
                            </th:block>
                            </tbody>
                        </table>
                    </div>
                </form>
                <div class="my-5">
                    <button type="button" class="btn btnBack" onclick="location.href='/top'">戻る</button>
                </div>
            </div>
        </div>
    </div>

    <div th:replace="common :: footer"></div>
</body>
</html>
```
</div></details>

### ログを出す
デバッグではどんなSQLが発行されているのか、パラメータにどんな値が入っているのか確認できず、ログを出力したいと思いました。
調べたらすぐ出てくる[Spring Boot + MyBatisでSQLログを出力する方法](https://confrage.jp/spring-boot-mybatis%E3%81%A7sql%E3%83%AD%E3%82%B0%E3%82%92%E5%87%BA%E5%8A%9B%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95/)で出力できました。
application.propertiesにDB情報を入力してあると思いますが、その下に下記を追加するだけで簡単です。
```application.properties
logging.level.org.springframework=WARN
logging.level.com.mailnaxx.mapper.UsersMapper=DEBUG
```

ログはこのように出ました。
```
Preparing: SELECT * FROM users WHERE deleted_flg = '0' AND role_class = ?
```

### 公式ドキュメントを確認する
Mapper.xmlのパラメータか、if文内の項目の書き方かなと予想しました。
まずはネットで調べるのですが、よく分からなくなってくるので[公式ドキュメント](https://mybatis.org/mybatis-3/ja/dynamic-sql.html)を確認しました。
MyBatisに限らず公式ドキュメントは例が少ないので、Formクラスの項目の場合は項目だけでいいのか、クラス名をつけるべきなのか、Mapper.javaの書き方も含めいろいろ試しました。

### 一部コメントアウトして何が原因かを探る
いつもここに至るまでいろいろ調べたり試したりして時間がかかってしまうのですが、コメントアウトして少しずつ動かして確かめることが大事です。
まずはMapper.xmlのSQL内if文を丸々コメントアウトしました。
ログは以下。パラメータがある時はこのようにログが出力されるのだと分かりました。
```
Preparing: SELECT * FROM users WHERE deleted_flg = '0' AND user_name LIKE ? || '%' AND role_class = ?
Parameters: い(String), 1(String)
```

### 結果
~~if文のsearchCondition（ラジオボタン）の値は文字列ではなく数値で、「searchCondition == 0」とすると検索できました。~~
~~html上は「th:value="0"」と書いており、Formクラス上でもStringにしていた、デバッグでも「"0"」と出るのでStringだと思っていました。~~

↑と思ったのですが、MyBatisの仕様かなと思い、「MyBatis if 文字列」と調べると以下の記事が出てきました。
[MyBatisの条件分岐(<if>など)で文字列リテラルを利用する際の注意点](https://qiita.com/kazuki43zoo/items/dbdaad338f2f1ae5b256)
それにある通り、ダブルクォーテーションとシングルクォーテーションを入れ替えたり、「searchCondition == '0'.toString()"」としても検索できました。

#### After
修正ファイルのみ載せます。
if文内のダブルクォーテーションとシングルクォーテーションを入れ替えたものです。
```UsersMapper.xml
<mapper namespace="com.mailnaxx.mapper.UsersMapper">
    <select id="findBySearchForm" parameterType="com.mailnaxx.form.SearchUsersForm" resultType="com.mailnaxx.entity.Users">
        SELECT * FROM users
        WHERE deleted_flg = '0'
        <if test='userName != "" and searchCondition == "0"'>
            AND user_name LIKE #{userName} || '%'
        </if>
        <if test='userName != "" and searchCondition == "1"'>
            AND user_name LIKE '%' || #{userName} || '%'
        </if>
        <if test='roleClass != ""'>
            AND role_class = #{roleClass}
        </if>
    </select>
</mapper>
```

#### 余談
検索条件に「前方一致」とか「部分一致」を付けたのは、過去のプロジェクトの仕様にあったからです。
しかし後で、操作する側は「前方一致」とか「部分一致」って分かるのだろうかと思いました。
今回は氏名の検索なので、あいまい検索としてラジオボタンは無くしてしまった方がいいのではないかと思いました。

### 最後に
久しぶりのMyBatisでした。
実際のプロジェクトだと個人開発では思いつかないような要件があり、.NETでやったものをSpringBootでやってみたのですが勉強になりました。
ログを確認する、公式ドキュメントを確認する、少しずつコメントアウトして確認することは大切だと改めて思いました。