概要
任意の組み合わせ条件で検索できる検索機能を作りたい。
検索条件の組み合わせの全パターンをRepository
に書いて呼び出し側で分岐すればできるが、分岐祭りになるのでそれはやりたくない。
findBy
の後ろが動的に変化させられれば解決するのになぁ、と思って調べたらSpring Data Jpa Specification
を使って動的にクエリーを変化できて超便利だったのでメモ。
環境
- Java 1.8
- SpringBoot 2.2.1.RELEASE
- Thymeleaf 3.0.11.RELEASE
サンプル
開始日と終了日を持つ予約情報を検索する場合のサンプル。
画面イメージ
- 検索条件は、ユーザー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でヘッダーフッターを共通化する方法 をどうぞ
※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 = "";
}
}
}