LoginSignup
3
5

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-09-17

概要

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

環境

  • Java 1.8
  • SpringBoot 2.2.1.RELEASE
  • Thymeleaf 3.0.11.RELEASE

サンプル

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

画面イメージ

画面のイメージはこんな感じ。
Inkedsearch-sample_edited_LI.jpg

  • 検索条件は、ユーザー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を検索するだけであればTUsrReservationに置き換えて作成すれば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 = "";
        }
    }
}

参考

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5