Spring Data Jpa Specificationを使って複数条件での検索機能を作る

# 概要
任意の組み合わせ条件で検索できる検索機能を作りたい。
検索条件の組み合わせの全パターンを`Repository`に書いて呼び出し側で分岐すればできるが、分岐祭りになるのでそれはやりたくない。
`findBy`の後ろが動的に変化させられれば解決するのになぁ、と思って調べたら`Spring Data Jpa Specification`を使って動的にクエリーを変化できて超便利だったのでメモ。

# 環境
- Java 1.8
- SpringBoot 2.2.1.RELEASE
- Thymeleaf 3.0.11.RELEASE

# サンプル
開始日と終了日を持つ予約情報を検索する場合のサンプル。

## 画面イメージ
画面のイメージはこんな感じ。
![Inkedsearch-sample_edited_LI.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265229/0350df73-9221-aec0-f047-ccaa15da1e78.jpeg)

- 検索条件は、ユーザーID、氏名、開始日、終了日
 - 氏名は姓/カナ姓を持っていて、どちらかに当てはまればOK
 - 開始日は入力日付以降(入力日付含む)で検索
 - 終了日は入力日付以前(入力日付含む)で検索
- 入力された項目のAND検索で検索する

例) 氏名に「山田」、開始日に「2021/04/01」を入力した場合
氏名(姓orカナ姓)が「山田」で、開始日が「2021/04/01」以降の予約データを検索結果に表示する

## DAO / DTO

### 検索対象のEntity

```UsrReservation.java
package com.tamorieeeen.sample.dao.entity;

import java.time.LocalDate;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import lombok.Getter;
import lombok.Setter;

/**
 *
 * @author tamorieeeen
 *
 */
@Getter
@Setter
@Entity
public class UsrReservation {

    // 予約データの主キーは予約IDで、auto_incrementで自動採番
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int reservationId;
    // 開始日
    private LocalDate startDate;
    // 終了日
    private LocalDate endDate;
    // ユーザーID
    private int userId;
    // 姓
    private String familyName;
    // カナ姓
    private String familyNameRuby;
}
```

### RepositoryにJpaSpecificationExecutorをextendsさせる

```UsrReservationRepository.java
package com.tamorieeeen.sample.dao.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import com.tamorieeeen.sample.dao.entity.UsrReservation;

/**
 *
 * @author tamorieeeen
 *
 */
@Repository
public interface UsrReservationRepository extends
    JpaRepository<UsrReservation, Integer>, JpaSpecificationExecutor<UsrReservation> {
}
```

### UsrSpecificationを作成する
同様の検索機能を別Entityに対しても作る必要があり、汎用的に使いたかったのでジェネリクスにて作成。
もし汎用的にする必要がなく`UsrReservation`を検索するだけであれば`T`を`UsrReservation`に置き換えて作成すればOKです。

```UsrSpecification.java
package com.tamorieeeen.sample.dao.specification;

import java.time.LocalDate;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.jpa.domain.Specification;

/**
 *
 * @author tamorieeeen
 *
 */
public class UsrSpecification<T> {

    /**
     * userIdで検索
     */
    public Specification<T> userIdEqual(Integer userId) {

        return userId == null ? null : new Specification<T>() {
            @Override
            public Predicate toPredicate(Root<T> root,
                    CriteriaQuery<?> query, CriteriaBuilder builder) {

                return builder.equal(root.get("userId"), userId);
            }
        };
    }

    // 上記は従来の書き方で面倒なので以降はラムダ形式で記載
    /**
     * familyName/familyNameRubyで検索
     * -> OR検索なのでPredicate[]を使う
     */
    public Specification<T> nameContains(String name) {

        return StringUtils.isEmpty(name) ? null : (root, query, builder) -> {
            Predicate[] predicates = {
                    builder.like(root.get("familyName"), "%" + name + "%"),
                    builder.like(root.get("familyNameRuby"), "%" + name + "%")
            };

            return builder.or(predicates);
        };
    }

    /**
     * startDateで検索
     */
    public Specification<T> startDateGreaterThanEqual(LocalDate startDate) {

        return startDate == null ? null : (root, query, builder) -> {
            return builder.greaterThanOrEqualTo(root.get("startDate"), startDate);
        };
    }

    /**
     * endDateで検索
     */
    public Specification<T> endDateLessThanEqual(LocalDate endDate) {

        return endDate == null ? null : (root, query, builder) -> {
            return builder.lessThanOrEqualTo(root.get("endDate"), endDate);
        };
    }
}
```

## Model / Controller / Service

### 検索条件の受け渡しに使うModel

```SearchModel.java
package com.tamorieeeen.sample.model;

import java.time.LocalDate;

import org.springframework.format.annotation.DateTimeFormat;

import lombok.Getter;
import lombok.Setter;

/**
 *
 * @author tamorieeeen
 *
 */
@Getter
@Setter
public class SearchModel {

    private int userId;

    private String name;

    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate startDate;

    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate endDate;
}
```
### 実際に検索を行うService

```ReservationService.java
package com.tamorieeeen.sample.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;

import com.tamorieeeen.sample.dao.entity.UsrReservation;
import com.tamorieeeen.sample.dao.repository.UsrReservationRepository;
import com.tamorieeeen.sample.dao.specification.UsrSpecification;
import com.tamorieeeen.sample.model.SearchModel;

/**
 *
 * @author tamorieeeen
 *
 */
@Service
public class ReservationService {

    @Autowired
    private UsrReservationRepository usrReservationRepository;

    public List<UsrReservation> getSearchReservations(SearchModel target) {

        // userIdが0の場合は初期値なので検索対象外
        Integer userId = target.getUserId() == 0 ? null : target.getUserId();
        UsrSpecification<UsrReservation> spec = new UsrSpecification<>();

        return usrUsrReservationRepository.findAll(
                Specification.where(spec.userIdEqual(userId))
                    .and(spec.nameContains(target.getName()))
                    .and(spec.startDateGreaterThanEqual(target.getStartDate()))
                    .and(spec.endDateLessThanEqual(target.getEndDate())));
    }
}
```

### 呼び出しController

```ReservationController.java
package com.tamorieeeen.sample.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;

import com.tamorieeeen.sample.model.SearchModel;
import com.tamorieeeen.sample.service.ReservationService;

/**
 *
 * @author tamorieeeen
 *
 */
@Controller
public class ReservationController {

    @Autowired
    private ReservationService reservationService;

    /**
     * 検索画面
     */
    @GetMapping("/reservation/search")
    public String search(Model model) {

        model.addAttribute("target", new SearchModel());

        return "search";
    }

    /**
     * 検索結果
     */
    @GetMapping("/reservation/search/result")
    public String searchResult(Model model,
            @ModelAttribute("target") SearchModel target) {

        model.addAttribute("reservations",
            reservationService.getSearchReservations(target));

        return "search/result";
    }
}
```

## フロント側

### 検索画面のThymeleaf

```search.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="common :: meta_header('検索',~{::link},~{::script},~{::meta})">
    <link rel="stylesheet" href="/css/search.css" />
    <script src="/js/search.js"></script>
</head>
<body>
    <h1>tamorieeeen</h1>
    <h2>検索ページサンプル</h2>
    <div class="search_zone">
        <h3>検索</h3>
        <form th:action="@{/reservation/search/result}" method="get" th:object="${target}" name="search_box">
            <div class="flex_container">
                <div class="flex_item">
                    <span class="search_item">ユーザーID</span><br>
                    <input type="text" th:field="*{userId}" />
                </div>
                <div class="flex_item">
                    <span class="search_item">氏名/カナ(姓)</span><br>
                    <input type="text" th:field="*{name}" />
                </div>
            </div>
            <div class="flex_container">
                <div class="flex_item">
                    <span class="search_item">開始日(以降)</span><br>
                    <input type="date" th:field="*{startDate}" th:value="*{startDate}" />
                </div>
                <div class="flex_item">
                    <span class="search_item">終了日(以前)</span><br>
                    <input type="date" th:field="*{endDate}" th:value="*{endDate}" />
                </div>
            </div>
            <div class="button_area">
                <button type="button" onclick="formClear();">クリア</button>
                <button type="submit"><img src="/img/search.svg"> 検索</button>
            </div>
        </form>
    </div>
</body>
```
※検索結果のthymeleafは検索結果のリストを表示するだけなので省略
※headerは共通化しているので気になる人は [Thymeleafでヘッダーフッターを共通化する方法](https://qiita.com/tamorieeeen/items/4bc818e18f4ff8bdf0c2) をどうぞ
※CSSは省略

### フォームクリア用のjs

```search.js
function formClear() {
    const elms = document.forms['search_box'].elements;
    for (let i = 0; i < elms.length; i++) {
        if (elms[i].type == "text" || elms[i].type == "date") {
            elms[i].value = "";
        }
    }
}
```

# 参考
- [Spring Data JPA の Specificationでらくらく動的クエリー](https://qiita.com/tag1216/items/3a408d2751a6310e2948)
- [Spring Data Jpa Specification で OR 検索する](https://qiita.com/arahori/items/26d3a599a1fdcbb35800)