findByStatusAndCategoryNameOrderByCreatedAtDesc みたいなメソッド名がどんどん長くなって、「これ以上は無理だな」と手が止まった経験はありませんか。JOINや集計、一括UPDATEになると、クエリメソッドの命名規則ではそもそも表現できないんですよね。

そこで登場するのが @Query アノテーションです。Spring BootのSpring Data JPAリポジトリに、JPQLやネイティブSQLをそのまま書けるので、複雑な検索も更新も1つのインターフェースに収まります。

今回はJPQLの基本と @ParamnativeQuery = true との使い分け、@Modifying による更新・削除、ページング時の countQuery、SpEL、LIKE検索のエスケープまで、コピペで動くコードで押さえていきます。クエリメソッドの命名規則そのものはクエリメソッドの記事で扱っているので、そちらに任せますね。

前提はSpring Boot 3.x + Spring Data JPA(Hibernate 6系)です。DBはPostgreSQLを想定しつつH2でも動くように書いていますが、DB固有の挙動が絡む箇所はその場で断りを入れます。

@Queryを使うべき場面

まずは「どれを使うか」の判断です。迷ったらこの表で決めましょう。

やりたいこと選ぶもの
単純な条件(等価、範囲、ORDER BY程度)クエリメソッド
JOIN、集計、特定カラムだけ取得@Query(JPQL)
DB固有関数、Window関数、SQLを完全に制御したい@Query(ネイティブSQL)
画面の入力によって条件が増えたり減ったりするSpecification / Querydsl

ポイントは最後の行です。「条件が動的に変わる」検索は @Query の苦手分野なので、SpecificationQuerydslの領域だと割り切ったほうが楽です。@Query は「条件は固定だけどクエリメソッドでは書けない」ケースで力を発揮します。

サンプルのエンティティ

以降のコードで共通して使うエンティティです。ProductCategory を持つシンプルな構成にしています。

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;
    private String status;   // "ACTIVE" / "ARCHIVED"
    private LocalDateTime createdAt;

    @ManyToOne(fetch = FetchType.LAZY)
    private Category category;
    // getter/setter 省略
}

@Entity
public class Category {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

JPQLで書く@Queryの基本

JPQLはSQLによく似ていますが、書くのは テーブル名・カラム名ではなくエンティティ名・プロパティ名 です。ここを間違えると起動時に落ちるので、最初に体に入れておきましょう。

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 基本形。Product はエンティティ名、p.status はプロパティ名
    @Query("SELECT p FROM Product p WHERE p.status = :status")
    List<Product> findByStatus(@Param("status") String status);

    // 関連エンティティの条件はパスで辿れる
    @Query("SELECT p FROM Product p WHERE p.category.name = :categoryName ORDER BY p.createdAt DESC")
    List<Product> findByCategoryName(@Param("categoryName") String categoryName);

    // 明示的にJOINしても同じ
    @Query("SELECT p FROM Product p JOIN p.category c WHERE c.name = :categoryName")
    List<Product> findByCategoryNameWithJoin(@Param("categoryName") String categoryName);

    // 集計。COUNTはLong、AVGはDoubleで受ける
    @Query("SELECT COUNT(p) FROM Product p WHERE p.status = :status")
    long countByStatus(@Param("status") String status);

    @Query("SELECT AVG(p.price) FROM Product p WHERE p.category.id = :categoryId")
    Double averagePriceByCategory(@Param("categoryId") Long categoryId);
}

p.category.name のようにドットで関連を辿れるのがJPQLの気持ちいいところです。裏では自動的にJOINが張られます。

細かい話ですが、SELECT などのキーワードは大文字小文字を区別しない一方、ProductcreatedAt のようなエンティティ名・プロパティ名は 大文字小文字が区別されます。Javaのクラス定義をそのまま写す気持ちで書きましょう。

もう一つ、JPQLの嬉しい点は 構文ミスが起動時に検出される ことです。プロパティ名を間違えると QueryCreationException(中身は Validation failed for query for method ...)でアプリが起動しません。本番で初めて気づくよりずっと安全ですよね。エラーメッセージにはメソッド名が含まれるので、そこを見て直せばOKです。なお spring.data.jpa.repositories.bootstrap-modelazy にしている場合は、起動時ではなくそのリポジトリが最初に使われたタイミングで同じ例外が出ます(deferred は起動完了時の ContextRefreshedEvent で検証されるので、失敗は起動時に分かります)。

@Paramと位置パラメータ、どっちを使う?

パラメータの渡し方は2通りあります。

// 位置パラメータ。引数の順番と ?1 ?2 が対応する
@Query("SELECT p FROM Product p WHERE p.status = ?1 AND p.price <= ?2")
List<Product> findCheapPositional(String status, BigDecimal maxPrice);

// 名前付きパラメータ。こちらを推奨
@Query("SELECT p FROM Product p WHERE p.status = :status AND p.price <= :maxPrice")
List<Product> findCheap(@Param("status") String status, @Param("maxPrice") BigDecimal maxPrice);

// IN句にはそのままコレクションを渡せる
@Query("SELECT p FROM Product p WHERE p.status IN :statuses")
List<Product> findByStatuses(@Param("statuses") Collection<String> statuses);

位置パラメータは引数の順番を入れ替えたときに静かに壊れるので、実務では名前付き一択でいいと思います。

なお、コンパイル時に -parameters オプションが有効なら @Param を省略しても引数名で解決されます。spring-boot-starter-parent を継承したMavenプロジェクトやSpring BootのGradleプラグインはデフォルトでこのオプションを付けてくれますが、ビルド設定に依存するコードは怖いので、私は @Param を書く派です。

名前を打ち間違えると、Spring Data側が IllegalStateException を投げます。メッセージは Using named parameters for method ... but parameter ... not found in annotated query ... という形で、メソッド名と見つからなかったパラメータ名が載っているので、JPQL内の :name@Param("name") の綴りを見比べれば直せます。

nativeQuery=trueでネイティブSQLを書く

DB固有の関数を使いたい、エンティティに紐づかない集計をしたい、といった場面では nativeQuery = true にします。今度は テーブル名・カラム名をそのまま 書きます。

public interface ProductRepository extends JpaRepository<Product, Long> {

    // DATE_TRUNCで月別の登録件数を集計(PostgreSQL想定。H2 2.x以降でも動く)
    @Query(value = """
            SELECT DATE_TRUNC('month', created_at) AS "monthStart", COUNT(*) AS "cnt"
            FROM product
            WHERE status = :status
            GROUP BY DATE_TRUNC('month', created_at)
            ORDER BY "monthStart"
            """, nativeQuery = true)
    List<MonthlyCount> countMonthly(@Param("status") String status);

    // エンティティで受けるなら、全カラムをSELECTする(SELECT * が手軽)
    @Query(value = "SELECT * FROM product WHERE price > :price", nativeQuery = true)
    List<Product> findExpensiveNative(@Param("price") BigDecimal price);
}

// MonthlyCount.java
// 集計結果はインターフェースプロジェクションで受けると読みやすい
public interface MonthlyCount {
    Timestamp getMonthStart();   // java.sql.Timestamp
    Long getCnt();
}

ネイティブSQLでも名前付きパラメータはそのまま使えます。一方で戻り値をエンティティで受ける場合は、エンティティが持つ全カラムをSELECTしていないとマッピングに失敗します。一部カラムだけ欲しいなら、上の MonthlyCount のようにインターフェースで受けるか Object[] で受けましょう。

エイリアスをダブルクォートで囲っているのには理由があります。インターフェースプロジェクションはgetter名(getCnt なら cnt)と結果セットのエイリアスを突き合わせるのですが、クォート無しの識別子はPostgreSQLなら小文字、H2なら大文字(CNT)に正規化されます。この食い違いでgetterがnullを返すことがあるので、AS "cnt" のようにクォートで綴りを固定しておくのが無難です。

getMonthStart() の型も一言。timestamp列はJDBCからは java.sql.Timestamp で返ってくるので、Timestampで受けるのがいちばん確実です。LocalDateTime で受けることもできますが、Springの型変換に頼ることになり、環境によっては ConverterNotFoundException になります。出たらTimestampに戻して、自分で toLocalDateTime() を呼びましょう。

使い分けの基準はシンプルで、「JPQLで書けるならJPQL」です。ネイティブにすると起動時の構文検証が効かず、プロパティ名のリネームにも追従せず、DBを変えると動かなくなる可能性も出てきます。必要なときだけ使う、という位置づけがちょうどいいです。

@Modifyingと@TransactionalでUPDATE/DELETE

@Query で最もつまずくのがここです。更新・削除クエリには @Modifying@Transactional の両方が必要 で、どちらか片方を忘れるとそれぞれ別のエラーで怒られます。

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 一括ステータス更新。戻り値は更新件数
    @Modifying(clearAutomatically = true)
    @Query("UPDATE Product p SET p.status = :status WHERE p.id IN :ids")
    int updateStatusByIds(@Param("status") String status, @Param("ids") Collection<Long> ids);

    // 古いレコードの一括削除
    @Modifying
    @Query("DELETE FROM Product p WHERE p.createdAt < :threshold")
    int deleteOlderThan(@Param("threshold") LocalDateTime threshold);
}

@Service
public class ProductService {
    private final ProductRepository repository;
    // コンストラクタ省略

    @Transactional
    public int archive(List<Long> ids) {
        return repository.updateStatusByIds("ARCHIVED", ids);
    }
}

@Modifying は「このクエリはSELECTじゃないから executeUpdate で実行してね」という指示です。付け忘れるとSpring Dataは結果セットを取ろうとするので、Hibernateが例外を投げます。メッセージはHibernate 6.3以降なら Expecting a selection query, but found 'UPDATE Product p ...'(IllegalSelectQueryException)、6.0〜6.2では Expecting a SELECT query という形です。

@Transactional を忘れたときはこうなります。

org.springframework.dao.InvalidDataAccessApiUsageException:
  Executing an update/delete query
Caused by: jakarta.persistence.TransactionRequiredException:
  Executing an update/delete query

「Executing an update/delete query」と言われたらトランザクションがない、と覚えておけば即解決です。@Transactional はリポジトリのメソッドに直接付けても動きますが(伝播はデフォルトの REQUIRED)、サービス層で付け忘れてもエラーにならず、リポジトリ呼び出しごとに短いトランザクションが切られて業務処理のまとまりが失われます。境界はサービス層で切る、と決めておくほうが事故が少ないですね。伝播や分離レベルの詳細はトランザクション管理の記事を参照してください。

clearAutomaticallyとflushAutomatically

上の例で clearAutomatically = true を付けたのには理由があります。JPQLの一括UPDATEは永続化コンテキストを素通りしてDBを直接更新するので、同じトランザクション内ですでにロードしていたエンティティは 古い値のまま です。

@Transactional
public void archiveAndCheck(Long id) {
    Product before = repository.findById(id).orElseThrow(); // status = "ACTIVE"
    repository.updateStatusByIds("ARCHIVED", List.of(id));
    Product after = repository.findById(id).orElseThrow();
    // clearAutomatically が無いと after.getStatus() は "ACTIVE" のまま
}

clearAutomatically = true は実行後に永続化コンテキストをクリアして、次の findById がDBから読み直すようにします。逆に flushAutomatically = true は実行前に未反映の変更をDBへフラッシュし、一括クエリがそれを踏まえて動くようにするものです。一括更新のあとに同じトランザクションで読み書きするなら、両方付けておくのが安全です。

もう一点だけ注意すると、一括クエリでは @PreUpdate などのライフサイクルコールバックや @Version による楽観ロックは動きません。それらに依存しているエンティティは、素直に取得してからsetterで更新しましょう。

Pageableと組み合わせるときはcountQuery

@Query のメソッドに Pageable を足せば、そのまま Page<Product> を返せます。Spring Dataが元のJPQLから SELECT COUNT(...) を自動で派生させてくれる仕組みです。

単純なJOINや条件だけなら自動派生で問題ありません。GROUP BY や集計関数が入ると、機械的に書き換えたcountクエリでは正しい件数になりません。DISTINCT やコンストラクタ式は派生自体はされますが、意図通りの件数かSQLログで確認しておきたいところです。怪しいときは countQuery を明示しましょう。

public interface ProductRepository extends JpaRepository<Product, Long> {

    // JPQL。GROUP BYがあるので自動派生では正しい件数が出ない
    @Query(value = """
            SELECT c.name AS name, COUNT(p) AS cnt
            FROM Product p JOIN p.category c
            GROUP BY c.name
            """,
           countQuery = "SELECT COUNT(DISTINCT c.name) FROM Product p JOIN p.category c")
    Page<CategoryCount> countByCategory(Pageable pageable);

    // ネイティブSQL。value / countQuery / nativeQuery をセットで書く
    @Query(value = "SELECT * FROM product WHERE status = :status",
           countQuery = "SELECT COUNT(*) FROM product WHERE status = :status",
           nativeQuery = true)
    Page<Product> findByStatusNative(@Param("status") String status, Pageable pageable);

    // 総件数が要らないなら Slice で count を省略できる
    @Query("SELECT p FROM Product p WHERE p.status = :status")
    Slice<Product> findSliceByStatus(@Param("status") String status, Pageable pageable);
}

// CategoryCount.java
public interface CategoryCount {
    String getName();
    Long getCnt();
}

ネイティブSQLでは単純なSELECTならcountクエリが派生することもありますが、サブクエリやUNIONが入ると失敗しやすいです。ページングするネイティブクエリには最初から countQuery を書く、と決めてしまうのがおすすめです。

「次のページがあるか」だけ分かれば十分な無限スクロールなどでは Slice を返すとcountクエリ自体が発行されず、それだけで1クエリ減ります。

ネイティブSQLへの動的な Sort は、公式リファレンスでも「単純なクエリなら書き換えられる」という限定付きの機能なので、あまり信用しすぎないほうがいいです。使うならプロパティ名ではなくカラム名(Sort.by("created_at"))で渡し、SQLログで ORDER BY が正しく追記されたか確認してください。確実に並び順を制御したいなら、SQL側に ORDER BY を固定で書くか、JPQLで書き直すのが安全です。

SpEL(:#{})でクエリを柔らかくする

@Query の中ではSpELが使えます。全部を紹介すると長くなるので、実務で役立つ2パターンだけ見ておきましょう。使える式の一覧は公式リファレンスのTemplated Queries and Expressionsにまとまっています。

// 汎用の基底リポジトリ。#{#entityName} が実際のエンティティ名に置き換わる
@NoRepositoryBean
public interface SoftDeleteRepository<T, ID> extends JpaRepository<T, ID> {
    @Query("SELECT e FROM #{#entityName} e WHERE e.status <> 'ARCHIVED'")
    List<T> findAllActive();
}

public interface ProductRepository extends SoftDeleteRepository<Product, Long> {
    // 引数オブジェクトのプロパティを直接バインドできる
    @Query("SELECT p FROM Product p WHERE p.status = :#{#cond.status} AND p.price <= :#{#cond.maxPrice}")
    List<Product> search(@Param("cond") ProductSearchCondition cond);
}

#{#entityName} を使うと、複数のエンティティに共通する「論理削除されていないものだけ取る」といったクエリを基底インターフェースにまとめられます。:#{#cond.status} は検索条件オブジェクトをそのまま渡せて、引数が増えてもメソッドシグネチャが膨らみません。

ただしSpELで条件分岐を書き始めると、可読性が急降下します。「この条件がnullなら無視」が2つ以上出てきたら、Specificationに移るタイミングだと思ってください。

LIKE検索とワイルドカードのエスケープ

部分一致検索は、% をJPQL側で付けるかパラメータ側で付けるかの2通りです。

public interface ProductRepository extends JpaRepository<Product, Long> {

    // JPQL側で結合する。Spring Data JPAの拡張構文で %:keyword% と書ける
    @Query("SELECT p FROM Product p WHERE p.name LIKE %:keyword%")
    List<Product> searchByName(@Param("keyword") String keyword);

    // 標準JPQLならCONCATを使う
    @Query("SELECT p FROM Product p WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :keyword, '%'))")
    List<Product> searchByNameIgnoreCase(@Param("keyword") String keyword);

    // パラメータ側で結合する。呼び出し側が "%" + keyword + "%" を渡す。エスケープ文字は ! にする
    @Query("SELECT p FROM Product p WHERE p.name LIKE :pattern ESCAPE '!'")
    List<Product> searchByPattern(@Param("pattern") String pattern);

    // Spring Data JPA組み込みのSpEL関数でエスケープまで任せる
    @Query("SELECT p FROM Product p WHERE p.name LIKE %?#{escape([0])}% ESCAPE ?#{escapeCharacter()}")
    List<Product> searchByNameEscaped(String keyword);
}

@Service
public class ProductSearchService {
    private final ProductRepository repository;
    // コンストラクタ省略

    public List<Product> search(String keyword) {
        return repository.searchByPattern("%" + escapeLike(keyword) + "%");
    }

    // ! % _ をエスケープ。JPQL側の ESCAPE '!' と対にする
    static String escapeLike(String s) {
        return s.replace("!", "!!")
                .replace("%", "!%")
                .replace("_", "!_");
    }
}

最初の2つは呼び出し側が生のキーワードを渡すだけで済むので手軽です。問題は、ユーザーが %_ を入力したときです。_ は「任意の1文字」なので、a_c で検索すると abcaxc もヒットしてしまいます。

これを防ぐ王道は、パラメータ側で結合する方式にして、サービス層でエスケープしてから渡すやり方です。上の escapeLike がそれで、エスケープ文字に選んだ ! 自身と %_ の3文字を潰しています。エスケープ文字にはバックスラッシュを使いたくなりますが、Hibernate 6のHQLはJava風のエスケープシーケンスを解釈するため、Javaの文字列リテラルとしては ESCAPE '\\\\' と4本書かないと起動時に Validation failed for query で落ちます。! のような記号にしておくほうが事故がありません。

実はSpring Data JPA側にも用意があって、最後の searchByNameEscaped のように ?#{escape([0])}?#{escapeCharacter()} を組み合わせると、ユーティリティなしで %_ をエスケープしてくれます。エスケープ文字はデフォルトが \ で、@EnableJpaRepositories(escapeCharacter = ...) で変えられます。SQL Serverの [ ] のようなDB固有の追加ワイルドカードまでは面倒を見てくれない点だけ覚えておいてください。

大文字小文字を無視したいときは LOWER() を両側にかければOKですが、カラムに関数をかけると通常のインデックスが効かなくなる点は覚えておいてください。

ちなみに単純な部分一致なら、クエリメソッドの findByNameContainingIgnoreCase で十分です。無理に @Query にする必要はありません。

必要なカラムだけ取る - DTOプロジェクション

エンティティ全体ではなく idname だけ欲しい、という場面は多いですよね。JPQLならコンストラクタ式、ネイティブSQLならインターフェースプロジェクションが使えます。

// ProductSummary.java(com.example.dto パッケージ。JPQL側のFQCNと対応させる)
package com.example.dto;

public record ProductSummary(Long id, String name, BigDecimal price) {}

// ProductNameView.java
package com.example.dto;

public interface ProductNameView {
    Long getId();
    String getName();
}

// ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
    // コンストラクタ式。クラス名は完全修飾名(FQCN)で書く
    @Query("SELECT new com.example.dto.ProductSummary(p.id, p.name, p.price) FROM Product p WHERE p.status = :status")
    List<ProductSummary> findSummaries(@Param("status") String status);

    // インターフェースプロジェクション。エイリアスとgetter名を合わせる(クォートで綴りを固定)
    @Query(value = "SELECT id AS \"id\", name AS \"name\" FROM product WHERE status = :status", nativeQuery = true)
    List<ProductNameView> findNames(@Param("status") String status);
}

コンストラクタ式で Could not locate appropriate constructor と言われたら、SELECTしている列の型とコンストラクタの引数型がずれています。LongIntegerBigDecimalDouble のあたりが定番です。使い分けやネストした射影についてはプロジェクションの記事で詳しく書いています。

発行されたSQLを確認する習慣

@Query を書いたら、想定通りのSQLとバインド値になっているか一度はログで確認しましょう。countQueryの派生やLIKEのエスケープは、ログを見ないと正しく動いているか分かりません。

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE   # バインドパラメータの値も出す(Hibernate 6系)

ここでJOINが想定外に増えていたり、N+1が見えたりしたら、JPAパフォーマンス最適化の記事を見ながらfetch joinなどを検討してください。

エラーの逆引き表

ここまでに出てきたエラーを、症状から引けるようにまとめておきます。

症状原因対処
起動時に QueryCreationException(Validation failed for query)JPQLの構文ミス、エンティティ名やプロパティ名の誤りメッセージ中のメソッド名を手がかりにJPQLを修正
Executing an update/delete query@Transactional が無いサービス層に @Transactional を付ける
Expecting a selection query, but found ...@Modifying が無い更新・削除メソッドに @Modifying を付ける
parameter ... not found in annotated query@Param の名前とJPQLの :name の不一致綴りを揃える
Could not locate appropriate constructorコンストラクタ式の引数型がSELECT列の型と不一致DTOの引数型を合わせる
ConverterNotFoundException射影の戻り値型に変換できない(Timestamp→LocalDateTime等)JDBCが返す型で受ける
ページ総件数がおかしい、countでSQLエラーcountQuery 未指定(ネイティブ、GROUP BY)countQuery を明示する
一括更新後に古い値が読める永続化コンテキストが古いまま@Modifying(clearAutomatically = true)

まとめ

@Query の使い分けを最後に振り返ります。

ケース書き方
単純条件クエリメソッド
JOIN・集計・DTO取得@Query + JPQL
DB固有関数・複雑SQL@Query(nativeQuery = true)
条件が動的に変わるSpecification / Querydsl

落とし穴は3つだけ覚えておけば十分です。更新・削除には @Modifying@Transactional をセットで付ける、GROUP BYやネイティブSQLをページングするなら countQuery を明示する、ユーザー入力のLIKE検索は ESCAPE でエスケープする。

この3つを押さえておけば、クエリメソッドで書けなくなった瞬間に迷わず @Query へ移れるはずです。それでも条件が動的に膨らんできたら、SpecificationやQuerydslの記事に進んでみてください。