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句を追加します。
@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}"><<先頭</span>
<a th:if="${!page.isFirst}"
th:href="@{/hello(page = 0)}">
<<先頭 </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}">最後>></span> <a th:if="${!page.isLast}"
th:href="@{/hello(page = ${page.totalPages - 1})}">
最後>> </a></li>
</ul>
</div>
<div th:if="${!isAllOrSearch}">
<ul>
<li style="display: inline;"><span th:if="${page.isFirst}"><<先頭</span>
<a th:if="${!page.isFirst}"
th:href="@{'/search?keyWords=' + ${keyWords} + '&page=0'}">
<<先頭 </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}">最後>></span> <a th:if="${!page.isLast}"
th:href="@{'/search?keyWords=' + ${keyWords} + '&page=' + ${page.totalPages - 1}}">
最後>> </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のデザインパターンの名著があるらしいので買ってみよう。