はじめに

この記事は、Spring BootでWebアプリケーションを開発していて「JPAのRepositoryで部分一致検索を実装したはいいけど、思った通りの結果が返ってこない」という方を対象にしています。
記事を読むことで、JPAでLIKE検索を実装する際の落とし穴である「エスケープ文字」「前方一致・後方一致の違い」「Fetchタイミング」といったポイントを押さえ、正しい検索結果を得られるようになります。
筆者も商用サービスで「#」「_」「%」を含むキーワード検索を実装した際に、検索漏れと過剰ヒットの両方を経験したため、その教訓を共有します。

前提知識

  • Java 17以降の文法に慣れていること
  • Spring Boot 3.xでRESTエンドポイントを作成した経験があること
  • Spring Data JPAでRepositoryインターフェースを作成した経験があること

なぜ部分一致検索が“上手くいかない”のか

RDBMSのLIKE検索は、単純に%keyword%とすれば終わり、と思いきや、JPAの仕様と組み合わさると予期しない動作を引き起こします。代表的なケースを3つ挙げます。

  1. エスケープ文字(_%)がそのままクエリに含まれると「1文字以上マッチ」や「0文字以上マッチ」として解釈され、検索漏れ/過剰ヒットが起きる
  2. JPQL / ネイティブSQLのどちらで書くかによって、パラメータのバインド方法が異なり、意図しない文字列がエスケープされる
  3. Spring Data JPAのContainingキーワードはデフォルトで前方一致ではなく部分一致になるが、FetchタイミングでLazyLoadingにより関連エンティティが空になるケースがある

これらを踏まえた上で、実装を進めていきましょう。

ステップバイステップで実装してみる

ステップ1:エンティティとRepositoryの用意

まず、検索対象となるエンティティを定義します。ここでは商品マスタを例に取ります。

Java
@Entity @Table(name = "products") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "name", nullable = false, length = 255) private String name; @Column(name = "description", columnDefinition = "TEXT") private String description; // getter / setter は省略 }

Repositoryインターフェースは次のように作成します。

Java
public interface ProductRepository extends JpaRepository<Product, Long> { // 部分一致検索(デフォルト実装) List<Product> findByNameContaining(String keyword); }

この時点でGET /api/products?name=catとリクエストするとnamecatを含む商品が返ってきます。
ただし、ここでnameに「cat100%リネン」と入っていた場合、%がワイルドカードとして機能してしまい、想定外のレコードまでヒットする可能性があります。

ステップ2:エスケープ処理を自前で実装する

ワイルドカード文字をエスケープするためのヘルパーメソッドを用意します。

Java
public final class EscapeUtils { private EscapeUtils() {} public static String likeEscape(CharSequence raw) { return raw.toString() .replace("~", "~~") .replace("%", "~%") .replace("_", "~_") + "%"; } }

このメソッドをコントローラーで呼び出してからRepositoryに渡します。

Java
@RestController @RequestMapping("/api/products") @RequiredArgsConstructor public class ProductController { private final ProductRepository repository; @GetMapping public List<Product> search(@RequestParam String name) { String escaped = EscapeUtils.likeEscape(name); return repository.findByNameContaining(escaped); } }

Repository側でエスケープ文字を指定するため、JPQLを明示的に書きます。

Java
public interface ProductRepository extends JpaRepository<Product, Long> { @Query("select p from Product p where p.name like :name escape '~'") List<Product> findByNameContaining(@Param("name") String name); }

これで%_を含むキーワードも正しく検索できます。

ステップ3:FetchタイミングでN+1が発生しないよう FETCH JOIN

Product@ManyToOne Category categoryが存在すると仮定します。
デフォルトのfindByNameContainingではLazyLoadingのため、JacksonがJSONシリアライズするタイミングでN+1が発生し、カテゴリがnullやプロキシで返ることがあります。
これを防ぐため、FETCH JOINを明示します。

Java
@Query("select distinct p from Product p left join fetch p.category c where p.name like :name escape '~'") List<Product> findByNameContaining(@Param("name") String name);

distinctを付けることで、カテゴリの件数分だけProductが重複して返ることを防ぎます。

ハマったポイントとエラー解決

現象A:「cat%」を検索したら全件ヒットする

原因:エスケープ指定をせずに%がワイルドカードとして解釈された
対処:JPQLでescape '~'を付け、バインド前に~%とエスケープする

現象B:「c_t」で検索して「cat」「cot」「cut」すべてがヒット

原因:_が「任意の1文字」と解釈された
対処:同上で~_とエスケープ

現象C:検索結果のカテゴリがすべてnull

原因:JacksonシリアライズタイミングでLazyLoading例外、またはプロキシが初期化されない
対処:FETCH JOINで一発取得する、または@EntityGraphを利用する

まとめ

本記事では、Spring Boot + Spring Data JPAで部分一致検索を実装する際の「ワイルドカード文字のエスケープ」「FETCH JOINによるN+1回避」という2つのポイントを解説しました。

  • LIKE検索で予期しない結果が返る原因は、ワイルドカード文字(%_)がそのままクエリに含まれること
  • JPQLでescape句を明示し、エスケープ文字を事前に置換してやることで意図通りの検索が可能
  • 関連エンティティを即座に取得したい場合はFETCH JOINまたは@EntityGraphを活用する

これらを意識することで、ユーザーが入力したキーワードを正確に反映した検索機能を安全に実装できます。
次回は、PostgreSQLのILIKEや全文検索エンジン(Elasticsearch)を使った高度な検索手法について取り上げる予定です。

参考資料

  • Spring Data JPA - Reference Documentation: https://spring.io/projects/spring-data-jpa
  • JPA仕様書(JSR 338)Section 4.6.7: https://jcp.org/en/jsr/detail?id=338
  • 「SQLアンチパターン」第5章 リワイルドカード——正規表現とLIKE