LoginSignup
3
3

More than 3 years have passed since last update.

Spring Data JPAで動的にクエリを生成する(複数ワード検索)(ページング対応)

Last updated at Posted at 2021-01-07

JPAでGoogle検索みたいにスペース区切りで複数ワードのAND検索がしたい。
「転職 東京」みたいな。
さらにページングにも対応したい。

色々調べていたらSpecificationに辿り着いたので、実装方法を備忘録として残しておきます。
いくつか粗があるので参考程度に。

Entity

@Data
@Entity
@Table(name="account")
public class AccountEntity implements Serializable{

    /**
     * シリアルバージョンUID
     */
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="id")
    private Integer id;

    @Column(name="name")
    private String name;

    @Column(name="age")
    private Integer age;
}

Repository

JpaSpecificationExecutorを継承します。

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

@Repository
public interface AccountRepository extends JpaRepository<AccountEntity, Integer>, JpaSpecificationExecutor<AccountEntity> {
    Page<AccountEntity> findAll(Pageable pageable);
}

Specification

import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;

import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Component;


@Component
public class AccountSpecification {
    /**
     * 指定文字をユーザー名に含むアカウントを検索する。
     */
    public Specification<AccountEntity> nameLike(String name) {

    // 匿名クラス
        return new Specification<AccountEntity>() {
            //CriteriaAPI
            @Override
            public Predicate toPredicate(Root<AccountEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
                // 「name LIKE '%name%'」を追加
                return cb.like(root.get("name"), "%" + name + "%");
            }
        };
    }
    /**
     * 指定文字を年齢に含むアカウントを検索する。
     */
    public Specification<AccountEntity> ageEqual(String age) {

        return new Specification<AccountEntity>() {
            //CriteriaAPI
            @Override
            public Predicate toPredicate(Root<AccountEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) {

                // 数値変換可能かのチェック
                try {
                    Integer.parseInt(age);

                    // 「age = 'age'」を追加
                    return cb.equal(root.get("age"), age);

                } catch (NumberFormatException e) {
                    return null;
                }
            }
        };
    }
}

AND句・OR句で繋げる

ここでもand()やor()で繋げられますが、サービスクラスでwhere()やand()or()メソッド使った方がわかりやすいかも。

                cb.like(root.get("name"), "%" + name + "%");
                cb.and(cb.equal(root.get("age"), age));
                return cb.or(cb.equal(root.get("age"), age));

Service

findAllの引数でwhere()やand()メソッドを呼び出して、Where句やAnd句を追加します。

1語検索バージョン
@Service
public class AccountService {

    @Autowired
    AccountRepository repository;

    @Autowired
    AccountSpecification accountSpecification;

    public List<AccountEntity> searchAccount(String keyWords, Pageable pageable){

        //前後の全角半角スペースを削除
        String trimedkeyWords = keyWords.strip();

        // 全角スペースと半角スペースで区切る
        String[] keyWordArray = trimedkeyWords.split("[  ]", 0);

        // 「Select * From account」 + 「Where name LIKE '%keyWordArray[0]%'」
        return repository.findAll(Specification
                .where(accountSpecification.ageEqual(keyWordArray[0])), pageable);
    }
}

「Select * From account Where(name='name' OR age='age') AND(name='name' OR age='age')」みたいにWhere句やAnd句の()の中に入れたい時はWhere/Andメソッドの中にAnd/ORメソッドを入れ子にする。

複数ワード検索バージョン
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;

@Service
public class AccountService {

    @Autowired
    AccountRepository repository;

    @Autowired
    AccountSpecification accountSpecification;

    public Page<AccountEntity> findAll(Pageable pageable) {
        return repository.findAll(pageable);
    }

    public Page<AccountEntity> searchAccount(String keyWords, Pageable pageable){

        //前後の全角半角スペースを削除
        String trimedkeyWords = keyWords.strip();

        // 全角スペースと半角スペースで区切る
        String[] keyWordArray = trimedkeyWords.split("[  ]", 0);

        //todo ここのisBlankでのnullチェックが効いてない。nullがfalseになる。
        //nullか空文字なら全検索 1語ならWhere句追加 2語以上ならAnd句追加にしたい。
        if(keyWordArray.length == 1 && StringUtils.isBlank(keyWordArray[0])) {
            return repository.findAll(pageable);

        }else if(keyWordArray.length == 1) {
            // 「Select * From account Where (name LIKE '%keyWordArray[0]%' OR age = '%keyWordArray[0]%')
            return repository.findAll(Specification
                    .where(accountSpecification.nameLike(keyWordArray[0])
                    .or(accountSpecification.ageEqual(keyWordArray[0]))), pageable);

        }else {
            Specification<AccountEntity> specification =
                    Specification.where(accountSpecification.nameLike(keyWordArray[0])
                            .or(accountSpecification.ageEqual(keyWordArray[0])));

            // 「Select * From account Where(name LIKE '%keyWordArray[0]%' OR age = '%keyWordArray[0]%') AND(name LIKE '%keyWordArray[i]%' OR age = '%keyWordArray[i]%') AND ・・・
            for(int i = 1; i < keyWordArray.length; i++) {
                specification = specification.and(accountSpecification.nameLike(keyWordArray[i])
                        .or(accountSpecification.ageEqual(keyWordArray[i])));
            }
            return repository.findAll(specification, pageable);
        }
    }
}

Controller

@Controller
public class AccountController {
    //全表示 or 検索の判定用
    boolean isAllOrSearch;

    @Autowired
    AccountService accountService;

    //アカウント全表示
    @GetMapping("/hello")
    public String getHello( @PageableDefault(page=0, size=2)Pageable pageable, Model model) {
        isAllOrSearch = true;
        Page<AccountEntity> accountAll = accountService.findAll(pageable);

        model.addAttribute("isAllOrSearch", isAllOrSearch);
        model.addAttribute("page", accountAll);
        model.addAttribute("accountAll", accountAll.getContent());
        return "hello";
    }

    //アカウント検索
    @GetMapping("/search")
    public String getName(@RequestParam("keyWords")String keyWords, Model model, @PageableDefault(page = 0, size=2)Pageable pageable) {
        isAllOrSearch = false;
        Page<AccountEntity> accountAll = accountService.searchAccount(keyWords, pageable);

        model.addAttribute("keyWords", keyWords);
        model.addAttribute("isAllOrSearch", isAllOrSearch);
        model.addAttribute("page", accountAll);
        model.addAttribute("accountAll", accountAll.getContent());

        return "hello";
    }
}

HTML

アカウント全表示用と検索用でほぼ同じページネーションを書いてしまっている。
サービスクラスでのnullチェックが上手くいかないため。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
    <form action="/search" method="get">
        <input type="text" name="keyWords">
        <input type="submit">
    </form>

    <table>
        <tbody>
            <tr>
                <th>ID</th>
                <th>名前</th>
                <th>年齢</th>
            </tr>
            <tr th:each="account : ${accountAll}">
                <td th:text="${account.id}">id</td>
                <td th:text="${account.name}">名前</td>
                <td th:text="${account.age}">年齢</td>
            </tr>
        </tbody>
    </table>
    <!-- ページネーション -->
        <div th:if="${isAllOrSearch}">
            <ul>
                <li style="display: inline;"><span th:if="${page.isFirst}">&lt;&lt;先頭</span>
                    <a th:if="${!page.isFirst}"
                    th:href="@{/hello(page = 0)}">
                        &lt;&lt;先頭 </a></li>
                <li style="display: inline; margin-left: 10px;"><span th:if="${page.isFirst}"></span>
                    <a th:if="${!page.isFirst}"
                    th:href="@{/hello(page = ${page.number} - 1)}"></a></li>
                <li th:if="${!page.empty}" th:each="i : ${#numbers.sequence(0, page.totalPages - 1)}"
                    style="display: inline; margin-left: 10px;"><span
                    th:if="${i} == ${page.number}" th:text="${i + 1}">1</span> <a
                    th:if="${i} != ${page.number}"
                    th:href="@{/hello(page = ${i})}"> <span
                        th:text="${i+1}">1</span></a></li>
                <li style="display: inline; margin-left: 10px;"><span
                    th:if="${page.isLast}"></span> <a th:if="${!page.isLast}"
                    th:href="@{/hello(page = (${page.number} + 1))}"></a></li>
                <li style="display: inline; margin-left: 10px;"><span
                    th:if="${page.last}">最後&gt;&gt;</span> <a th:if="${!page.isLast}"
                    th:href="@{/hello(page = ${page.totalPages - 1})}">
                        最後&gt;&gt; </a></li>
            </ul>
        </div>

        <div th:if="${!isAllOrSearch}">
            <ul>
                <li style="display: inline;"><span th:if="${page.isFirst}">&lt;&lt;先頭</span>
                    <a th:if="${!page.isFirst}"
                    th:href="@{'/search?keyWords=' + ${keyWords} + '&page=0'}">
                        &lt;&lt;先頭 </a></li>
                <li style="display: inline; margin-left: 10px;"><span th:if="${page.isFirst}"></span>
                    <a th:if="${!page.isFirst}"
                    th:href="@{'/search?keyWords=' + ${keyWords} + '&page=' + ${page.number - 1}}"></a></li>
                <li th:if="${!page.empty}" th:each="i : ${#numbers.sequence(0, page.totalPages - 1)}"
                    style="display: inline; margin-left: 10px;"><span
                    th:if="${i} == ${page.number}" th:text="${i + 1}">1</span> <a
                    th:if="${i} != ${page.number}"
                    th:href="@{'/search?keyWords=' + ${keyWords} + '&page=' + ${i}}"> <span
                        th:text="${i+1}">1</span></a></li>
                <li style="display: inline; margin-left: 10px;"><span
                    th:if="${page.isLast}"></span> <a th:if="${!page.isLast}"
                    th:href="@{'/search?keyWords=' + ${keyWords} + '&page=' + ${page.number + 1}}"></a></li>
                <li style="display: inline; margin-left: 10px;"><span
                    th:if="${page.last}">最後&gt;&gt;</span> <a th:if="${!page.isLast}"
                    th:href="@{'/search?keyWords=' + ${keyWords} + '&page=' + ${page.totalPages - 1}}">
                        最後&gt;&gt; </a></li>
            </ul>
        </div>
</body>
</html>

Specificationパターン

Specificationパターンとは、仕様を満たすことを目的にしたデザインパターン。
例えば人材探しで条件と候補者を分ける。
普通ならIF文とかSQLのWhere句とかで実装する。

specificationパターンでは
and() や or() メソッドで、個々の条件判断オブジェクトを、追加していく。

今回の例で言えば、条件判断の元がEntityに入ってて、andやorメソッドで条件を追加していく。
(間違っていたらごめんなさい)

参考

Spring Data JPA の Specificationでらくらく動的クエリー
[JPA] DB検索時の条件を動的に設定する
JPA Specificationで複数キーワードによる絞り込み検索
Specification パターン :複雑な ビジネスルールの表現手段

終わりに

ここのところSpringBootやJPAに触れていてインターフェースの中身を覗くことが多かった。
抽象的なコードを理解するために、デザインパターンを知っておくと良さそう。
Javaのデザインパターンの名著があるらしいので買ってみよう。

3
3
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
3