1. はじめに
新卒エンジニアとして配属されてから2か月が経ちました。現在は研修の一環で、簡単なWebアプリケーションの開発に取り組んでいます。
そのアプリ開発の中で、「複数キーワードのスペース分割検索」や「タグのAND/OR絞り込み」といった 複雑な複合検索機能(動的クエリ) を実装する機会がありました。
最初は「検索条件が入力された時だけ、SQLの文字列を結合すればいいだろう」と考えていました。しかし条件が複雑になるにつれ、コードも複雑になり、文字列結合による実装の限界を感じました。
そこで Spring Data JPAの「Specification」 を利用した型安全な実装に切り替えたのですが、Criteria API特有の記述方法の難度が高く、ロジックを組み立てるまでにかなり苦戦しました。
この記事では、「なぜ文字列結合ではなくSpecificationを使うべきなのか」 という背景から、「実務でよくある複雑な検索要件の実装例」 をまとめます。
この記事で得られること
- 従来の「SQL文字列結合」のリスクと、Specificationのメリット・デメリット
- 1つの入力欄を使った「スペース区切りの複数キーワード検索」の実装方法
- 別テーブルを考慮した、複雑なAND/OR条件の組み立て方
2. @Queryの限界と文字列結合の辛さ
研修の初期段階では、キーワード検索機能を以下のように @Query アノテーションを使ってリポジトリ層に実装していました。
/**
* キーワードに一致するユーザー情報を検索し、指定された順序で並べ替えて取得する。
*
* @param keyword 検索キーワード
* @param sort 並べ替えのルール
* @return 条件に合致するユーザー情報のリスト
*/
@Query("SELECT u FROM User u "
+ "LEFT JOIN u.tags t "
+ "LEFT JOIN u.status s "
+ "WHERE CAST(u.id AS string) LIKE %:keyword% "
+ "OR u.name LIKE %:keyword% "
+ "OR u.nameKana LIKE %:keyword% "
+ "OR u.phone LIKE %:keyword% "
+ "OR u.emergencyContact LIKE %:keyword% "
+ "OR u.email LIKE %:keyword% "
+ "OR u.address LIKE %:keyword% "
+ "OR u.remarks LIKE %:keyword% "
+ "OR t.tagName LIKE %:keyword% "
+ "OR s.statusName LIKE %:keyword%")
List<User> searchByKeyword(@Param("keyword") String keyword, Sort sort);
この実装は、バインド変数(:keyword)を使っているため安全で、単一のキーワード検索であれば全く問題なく機能していました。
しかし、開発が進むにつれて 「複数のキーワードをスペース区切りで入力して、すべてのキーワードを含むユーザーを検索したい(AND検索)」 という要件が追加されました。ここで私は壁にぶつかりました。
動的に条件が変わる要件に @Query は弱い
@Query アノテーションは、「静的」なクエリを定義するには非常に便利です。しかし、今回のように「入力されたキーワードの数(配列の長さ)だけ、動的に AND (name LIKE ... OR email LIKE ...) のブロックをループで増やす」といった柔軟な対応ができません。
これを無理やり解決しようとすると、自力でクエリ文字列を結合していく、いわゆる 「SQL(JPQL)文字列結合」 に逆戻りしてしまいます。
String jpql = "SELECT u FROM User u LEFT JOIN ... WHERE 1=1 ";
for(String kw : keywords) {
jpql += " AND (u.name LIKE '%" + kw + "%' OR u.email LIKE '%" + kw + "%') ";
}
3. Spring Data JPAの「Specification」とは
文字列結合の辛さを解決し、動的クエリを安全に組み立てるための機能が、Spring Data JPAが提供する 「Specification」 です。
そもそも「Specification(仕様)パターン」とは、ドメイン駆動設計などで用いられるデザインパターンの一つです。複雑なビジネスルール(検索条件など)をオブジェクトとして部品化し、再利用や組み合わせを容易にするという設計思想を持っています。
メリットとデメリット
- メリット: Javaのオブジェクト指向のまま、型安全に条件を組み立てられます。IDEの入力補完も効くため構文エラーに気付きやすく、SQLインジェクションの心配もありません。また、条件ごとの使い回し(再利用)が簡単です。
- デメリット: 裏側でJPAの「Criteria API」という仕組みを使っているため、特有の記述方法を覚える必要があります。
JPA特有の3つの引数
Specificationを実装する際、必ず以下の3つの引数を持つメソッド(ラムダ式)を記述します。これらは、裏側で動いている 「JPA Criteria API」 と呼ばれる、JavaでSQLを安全に組み立てるための標準インターフェースたちです。最初は戸惑いますが、それぞれの役割をSQLの句に当てはめると理解しやすくなります。
-
root(Root インターフェース)-
役割: SQLの
FROM句です。 -
解説: 検索対象の根っこ(ルート)となるテーブルの情報を持ちます。ここから
root.get("name")のようにカラムを指定したり、root.join("tags")のように別テーブルを結合したりします。
-
役割: SQLの
-
query(CriteriaQuery インターフェース)-
役割: SQLの
SELECT句やORDER BY句などを管理します。 -
解説: 今回のようにJOINによって増えてしまった重複レコードを排除する
query.distinct(true)などの設定を行う際に使います。
-
役割: SQLの
-
cb(CriteriaBuilder インターフェース)-
役割: SQLの
WHERE句を組み立てます。 -
解説:
cb.equal()(一致)やcb.like()(あいまい検索)、cb.and()(かつ)、cb.or()(または)など、条件を生成します。
-
役割: SQLの
4. Specificationを使うための設定
実装に入る前の準備は非常に簡単です。対象となるEntityのRepositoryインターフェースに、JpaSpecificationExecutor を継承させるだけです。
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import com.example.demo.entity.User;
@Repository
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
// JpaSpecificationExecutorを継承することで、
// findAll(Specification<User> spec) などのメソッドが使えるようになります
}
5. 複雑な複合検索を実装する
ここからは、実際にSpecificationを使って書いた複合検索のコードを解説します。
私が研修で作成した「ユーザー(User)」「ステータス(Status)」「タグ(Tag)」の構成を例にしています。
【実装する要件】
- 複数キーワード検索: 1つの入力欄にスペース区切りで入力された全キーワードを含むユーザーを検索する(AND検索)。ただし、各キーワードは「名前」や「メールアドレス」「タグ名」など、いずれかの項目に一致すればOK(OR検索)。
- タグの複数選択検索: 選択された複数のタグIDについて、「すべて持つ(AND)」または「いずれかを持つ(OR)」で検索する。
- 全体の結合: 「キーワード条件」と「タグ条件」を掛け合わせる(AND結合)。
コードが少し長くなるため、処理のブロックごとに分割して解説していきます。
① 全体の枠組みと重複排除の設定
まずはSpecificationの土台となるメソッドと、検索条件を格納する変数を準備します。
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import com.example.demo.entity.Status;
import com.example.demo.entity.Tag;
import com.example.demo.entity.User;
public class UserSpecification {
public static Specification<User> searchConditions(String keyword, List<Long> tagIds, String tagCondition) {
return (root, query, cb) -> {
// 【ポイント①】JOINによる重複レコードの排除
query.distinct(true);
// キーワード条件とタグ条件を別々の変数として準備する
Predicate keywordPredicate = null;
Predicate tagPredicate = null;
ポイント①:重複レコードの排除 query.distinct(true)
User に対して Tag のような「1対多(または多対多)」のテーブルをJOINして検索すると、1人のユーザーが3つのタグを持っている場合、同じユーザーのレコードが3行取得されてしまいます。これを防ぐために、query.distinct(true) を指定して重複を排除しています。
② キーワード検索ブロック(スペース分割 & 複数項目OR検索)
次に、入力された文字列をスペースで分割し、それぞれのキーワードに対する検索条件を組み立てます。
// 1. キーワード検索の条件ブロック
if (keyword != null && !keyword.trim().isEmpty()) {
Join<User, Status> statusJoin = root.join("status", JoinType.LEFT);
Join<User, Tag> tagJoinForKeyword = root.join("tags", JoinType.LEFT);
// 【ポイント②】
// 全角スペースや特殊スペースをUnicodeで安全に半角スペースに置換してから分割
String[] keywords = keyword
.replace('\u3000', ' ') // 全角スペース
.replace('+', ' ')
.replace('+', ' ')
.split("\\s+");
// 各キーワードごとの条件をまとめるリスト
List<Predicate> andPredicatesForKeywords = new ArrayList<>();
for (String kw : keywords) {
if (kw.trim().isEmpty()) continue;
String likeKeyword = "%" + kw + "%";
List<Predicate> orPredicates = new ArrayList<>();
// 各フィールドに対するLIKE検索(OR条件)
orPredicates.add(cb.like(root.get("name"), likeKeyword));
orPredicates.add(cb.like(root.get("nameKana"), likeKeyword));
orPredicates.add(cb.like(root.get("phone"), likeKeyword));
orPredicates.add(cb.like(root.get("emergencyContact"), likeKeyword));
orPredicates.add(cb.like(root.get("email"), likeKeyword));
orPredicates.add(cb.like(root.get("address"), likeKeyword));
orPredicates.add(cb.like(root.get("remarks"), likeKeyword));
orPredicates.add(cb.like(statusJoin.get("statusName"), likeKeyword));
orPredicates.add(cb.like(tagJoinForKeyword.get("tagName"), likeKeyword));
// キーワードが数字のみで構成されている場合のみ、ID検索の条件を追加
if (kw.matches("^[0-9]+$")) {
Long idVal = Long.valueOf(kw);
orPredicates.add(cb.equal(root.get("id"), idVal));
}
// 【ポイント②-A】1つのキーワードに対する複数項目のOR条件を作り、リストに追加
andPredicatesForKeywords.add(cb.or(orPredicates.toArray(new Predicate[0])));
}
// 【ポイント②-B】すべてのキーワード条件(ORの塊)をANDで結合
keywordPredicate = cb.and(andPredicatesForKeywords.toArray(new Predicate[0]));
}
ポイント②:キーワードごとの「ORの塊」を「AND」で束ねる
このブロックこそ、文字列結合や @Query では実現が難しい Specification 最大の強みです。
例えば keywordA keywordB のように2語で検索された場合、実現したい条件は (各項目が keywordA に一致) AND (各項目が keywordB に一致) という入れ子構造になります。Specificationを使うと、この構造を Predicate として段階的に組み立てられるため、文字列連結より保守しやすくなります。
これを実現するために、以下の2段階の処理を行っています。
-
forループの中 (cb.or)
1つのキーワード(例:「山田」)に対して、すべてのカラムのlike条件をリストに詰め込み、cb.or(...)で1つの「ORブロック(Predicate)」にパッケージ化します。 -
forループの外 (cb.and)
キーワードの数だけ作られた「ORブロック」たちのリストを、最後にcb.and(...)で束ねます。
このように、「リストに条件を詰めて cb.or や cb.and に渡すだけ」で、どれだけキーワードが増えても破綻することなく、複雑なネスト構造を組み立てられます。
③ タグ複数検索ブロック(AND / OR の切り替え)
続いて、別テーブルであるタグの絞り込み条件です。
// 2. タグ複数検索の条件ブロック
if (tagIds != null && !tagIds.isEmpty()) {
List<Predicate> tagPredicatesList = new ArrayList<>();
if ("AND".equalsIgnoreCase(tagCondition)) {
// 【ポイント③】AND検索の場合、条件の数だけ都度JOINを発生させる
for (Long tagId : tagIds) {
Join<User, Tag> tagJoin = root.join("tags");
tagPredicatesList.add(cb.equal(tagJoin.get("tagId"), tagId));
}
tagPredicate = cb.and(tagPredicatesList.toArray(new Predicate[0]));
} else {
// OR検索の場合はIN句で一発
Join<User, Tag> tagJoin = root.join("tags");
tagPredicate = tagJoin.get("tagId").in(tagIds);
}
}
ポイント③:別テーブル(タグなど)の「AND検索」におけるJOINの罠
タグを「いずれかを持つ(OR)」で検索する場合は .in(tagIds) で簡単に書けます。
しかし、「指定されたタグをすべて持つ(AND)」を表現したい場合、1回のJOINに対して AND tag_id = 1 AND tag_id = 2 と指定してしまうと、「IDが1であり、かつ2であるタグ」を探すことになり、結果は0件になってしまいます。
そのため、AND検索の場合はループの中で root.join("tags") を毎回呼び出し、タグの数だけJOINを重ねる必要があります。これにより「タグ1を持つテーブル」と「タグ2を持つテーブル」を別々に結合してANDを取ることができ、正しい検索結果が得られます。
④ 全体の結合とリターン
最後に、組み立てた「キーワード条件」と「タグ条件」を結合して返却します。
// 3. 全体の結合(キーワード条件とタグ条件の掛け合わせ)
if (keywordPredicate != null && tagPredicate != null) {
return cb.and(keywordPredicate, tagPredicate);
} else if (keywordPredicate != null) {
return keywordPredicate;
} else if (tagPredicate != null) {
return tagPredicate;
} else {
// 条件が何も入力されていない場合は全件検索(常にtrueの条件を返す)
return cb.conjunction();
}
};
}
}
ポイント④:全件検索を安全に表現する cb.conjunction()
最後に、キーワードもタグも未入力だった場合の else ブロックに注目してください。ここではCriteria APIが提供する cb.conjunction() というメソッドを返しています。
これは、 「常にTrue(真)になる、空っぽの条件」 です。
条件がないときに下手に null を返してしまうと、結合時にエラー(NullPointerException)になってしまう危険があります。しかし、この cb.conjunction() を使うことで、@Queryアノテーションで書いたようなWHERE 1=1 と同じ「条件なし(全件取得)」という状態を、Javaのオブジェクトとして安全かつきれいに表現できます。
これで、どれだけ検索条件が複雑になっても、構文エラーやSQLインジェクションの心配がない、安全な動的クエリが完成しました。
最初はCriteria API特有の記述方法やロジックの組み立てを難しく感じていましたが、一つひとつの役割を理解し、条件を組み立てていく過程は、文字列結合とはまた違う面白さを感じました。
6. まとめ
今回は、「入力された条件の数だけ動的にWHERE句を組み立てる」という複雑な要件に対して、Spring Data JPAのSpecificationを用いた解決策をご紹介しました。
Specificationを使ってみた所感
JPAのCriteria API(root, query, cb)は、初見では完全に「呪文」にしか見えず、心が折れそうになりました。しかし、いざ仕組みを理解して実装を終えると、以下のようなメリットがあることを実感しました。
-
@Queryでは対応しきれない動的な要件に柔軟に対応できる
「(A or B) かつ (C or D)」のような複雑なネスト条件も、PredicateというJavaのオブジェクトとして扱い、cb.andやcb.orで束ねることで、型安全かつ可読性の高いコードを維持できます。 -
スペース忘れによる構文エラーや、SQLインジェクションを防ぐことができる
裏側で自動的にバインド変数(プレースホルダー)を利用した安全なSQLが発行されるため、文字列結合による見えないスペースの忘れや、インジェクションの脆弱性を根本から防げます。 -
cb.conjunction()のように、安全な全件検索が書ける
文字列結合で無理やり書いていたWHERE 1=1とは違い、常に真となる空の条件をJavaのメソッドとして安全に表現できます。
初見ではラムダ式の記述など複雑に感じますが、一度仕組みを理解すれば、文字列結合では実装困難な複雑な条件検索に対して、安全なクエリを組み立てられます。この記事が動的クエリの実装に悩んでいる方の参考になれば幸いです。