はじめに
この記事は、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文字以上マッチ」や「0文字以上マッチ」として解釈され、検索漏れ/過剰ヒットが起きる - JPQL / ネイティブSQLのどちらで書くかによって、パラメータのバインド方法が異なり、意図しない文字列がエスケープされる
- 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インターフェースは次のように作成します。
Javapublic interface ProductRepository extends JpaRepository<Product, Long> { // 部分一致検索(デフォルト実装) List<Product> findByNameContaining(String keyword); }
この時点でGET /api/products?name=catとリクエストするとnameにcatを含む商品が返ってきます。
ただし、ここでnameに「cat100%リネン」と入っていた場合、%がワイルドカードとして機能してしまい、想定外のレコードまでヒットする可能性があります。
ステップ2:エスケープ処理を自前で実装する
ワイルドカード文字をエスケープするためのヘルパーメソッドを用意します。
Javapublic 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を明示的に書きます。
Javapublic 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
