Spring Data JPA: Repository、Query Method、@Query
1. 概要
Spring Data JPA は、ほぼ実装を書かずにデータアクセス層を記述するのに役立ちます。インターフェースを宣言するだけで、Spring はメソッド名またはアノテーション @Query に基づいて SQL ステートメントを自動生成します。
この記事では 3 つの主な部分を説明します:基本的なリポジトリの作成方法、Spring が WHERE 条件を自動的に推測するためのクエリメソッドの命名規則、および複雑な JPQL/ネイティブ SQL が必要な場合にメソッド名では表現できないときに @Query を使用する方法。読み終わったら、実際の状況ごとに正しいアプローチを選択する方法がわかるようになります。
2. プロジェクトのセットアップ
Spring Data JPA とデータベースドライバ(デモ用の H2 など)の依存関係を追加します:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
この記事全体を通して使用するサンプルエンティティ:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerEmail;
private BigDecimal totalAmount;
private OrderStatus status;
private LocalDateTime createdAt;
// getters, setters
}
public enum OrderStatus {
PENDING, PAID, SHIPPED, CANCELLED
}
Order にはシンプルなフィールドがあります:顧客メール、合計金額、ステータス、作成時刻。以下のクエリ例はすべてこのエンティティを中心としています。
3. 基本的なリポジトリ
JpaRepository を継承するだけで、Spring Boot はスタートアップ時に bean の実装を自動生成します:
public interface OrderRepository extends JpaRepository<Order, Long> {
}
JpaRepository<Order, Long> は、管理するエンティティが Order で、主キーの型が Long であることを示します。このインターフェースには、save()、findById()、findAll()、deleteById() などのメソッドがすでに備わっており、コードを追加で書く必要はありません。
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Order createOrder(Order order) {
return orderRepository.save(order);
}
}
save() を呼び出すと、id が null の場合は INSERT され、id が既に存在する場合は UPDATE されます。これは Spring Data が自動生成する実装である SimpleJpaRepository のデフォルト動作です。
注意:
save()はビジネスロジックに基づいて「新規作成」か「更新」を区別するのではなく、idが null かどうかに基づいて判断します。save()を呼び出す前にidを手動で設定した場合、Spring はそれを更新と見なし、findById()+ merge を実行し、不要な SELECT クエリが 1 つ追加されます。
4. クエリメソッド:命名によるクエリ生成
4.1. 基本的なルール
Spring Data JPA は、メソッド名を findBy、existsBy、countBy、deleteBy のパターンに従って解析し、フィールド名と条件キーワードを組み合わせます:
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatus(OrderStatus status);
List<Order> findByCustomerEmailAndStatus(String email, OrderStatus status);
Optional<Order> findFirstByCustomerEmailOrderByCreatedAtDesc(String email);
long countByStatus(OrderStatus status);
}
findByStatusはWHERE status = ?を生成します。findByCustomerEmailAndStatusは 2 つの条件をANDで結合します。findFirstByCustomerEmailOrderByCreatedAtDescは 1 つのレコードを取得し、createdAtで降順にソートします — 顧客の最新の注文を見つけたいときに便利です。countByStatusは数量を返し、リストではありません。
4.2. 比較キーワードと範囲
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByTotalAmountGreaterThan(BigDecimal amount);
List<Order> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
List<Order> findByStatusIn(List<OrderStatus> statuses);
List<Order> findByCustomerEmailContainingIgnoreCase(String keyword);
}
キーワード GreaterThan、Between、In、ContainingIgnoreCase は Spring Data JPA がサポートしているキーワードで、対応する SQL 句に直接マップされます。ContainingIgnoreCase は LOWER(customer_email) LIKE LOWER('%keyword%') を生成します — 大文字小文字を区別しない検索に便利です。
動作を確認するテスト:
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrdersByStatus() {
Order order = new Order();
order.setStatus(OrderStatus.PAID);
order.setCustomerEmail("a@test.com");
order.setTotalAmount(BigDecimal.valueOf(100));
order.setCreatedAt(LocalDateTime.now());
orderRepository.save(order);
List<Order> result = orderRepository.findByStatus(OrderStatus.PAID);
assertThat(result)
.hasSize(1)
.first()
.extracting(Order::getCustomerEmail)
.isEqualTo("a@test.com");
}
}
@DataJpaTest は JPA 関連のコンテキスト(リポジトリ、エンティティマネージャー)のみをロードするため、@SpringBootTest より高速です。デフォルトではメモリ内データベースを使用し、各テスト後にロールバックします。
4.3. ページング とソート
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);
System.out.println(page.getTotalElements());
System.out.println(page.getContent());
Pageable は特別なパラメーターです — Spring Data は自動的に認識し、WHERE 条件とは見なしません。Page<Order> はメタデータとともに返されます:総要素数、総ページ数、現在のページ内容。
4.4. メソッド名が複雑になる場合
// 読みにくく、タイプミスしやすいキーワード
List<Order> findByStatusAndCustomerEmailContainingIgnoreCaseAndCreatedAtAfterOrderByTotalAmountDesc(
OrderStatus status, String keyword, LocalDateTime after);
上記のような長いメソッド名は機能しますが、読みにくく、保守が難しく、コンパイラは構文エラーをチェックするのに役立ちません — Spring Data はコンテキストの起動時にのみエラーを検出します(フェイルファストですが、実行時です)。これは @Query に切り替えるべき時です。
5. @Query:ステートメント制御
5.1. 基本的な JPQL
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.totalAmount > :minAmount")
List<Order> findHighValueOrders(@Param("status") OrderStatus status,
@Param("minAmount") BigDecimal minAmount);
}
@Query は JPQL を記述します — エンティティ指向のクエリ言語であり、純粋な SQL ではなく(テーブル/列名の代わりに Order o、o.status を使用します)。@Param はクエリ内のパラメーター名をメソッドパラメーターにマップし、変数名を柔軟に保つことができます。順序に依存しません。
5.2. ネイティブ SQL が必要な場合
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query(value = "SELECT * FROM orders WHERE total_amount > :minAmount LIMIT :limit",
nativeQuery = true)
List<Order> findTopOrders(@Param("minAmount") BigDecimal minAmount,
@Param("limit") int limit);
}
nativeQuery = true により、実際の SQL を記述でき、LIMIT などのデータベース固有の構文を使用できます。その代わり、異なるデータベース間での移植性が失われます(たとえば、Oracle は LIMIT をサポートしていません)。
注意: ネイティブクエリはカラム名がマッピングと一致する場合、エンティティを返すことができますが、カスタム DTO を SELECT する場合は、
@SqlResultSetMappingを追加するか、プロジェクションインターフェースを使用する必要があります — JPQL より複雑です。
5.3. @Modifying による UPDATE と DELETE
public interface OrderRepository extends JpaRepository<Order, Long> {
@Modifying
@Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus")
int updateStatusBulk(@Param("oldStatus") OrderStatus oldStatus,
@Param("newStatus") OrderStatus newStatus);
}
@Modifying は Spring Data に、これがデータ変更ステートメント(UPDATE/DELETE)であり、SELECT ではないことを知らせます。メソッドは影響を受けた行数として int を返します。@Modifying がない場合、JPA が ResultSet をマップすることを期待するため、InvalidDataAccessApiUsageException エラーが発生します。
注意:
@Modifyingによるバルク更新はデータベースに直接実行され、エンティティライフサイクルを通過しません(@PreUpdateをトリガーしない、永続性コンテキストで管理されているエンティティのキャッシュを更新しない)。サービスが古いOrderインスタンスを保持している場合、メモリ内のデータは同期されません —@ModifyingにclearAutomatically = trueが必要です。またはentityManager.clear()を手動で実行します。
5.4. プロジェクション:必要なフィールドのみ取得
エンティティ全体が不要な場合、インターフェースプロジェクションを使用して返すデータを削減します:
public interface OrderSummary {
Long getId();
String getCustomerEmail();
BigDecimal getTotalAmount();
}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o.id AS id, o.customerEmail AS customerEmail, o.totalAmount AS totalAmount " +
"FROM Order o WHERE o.status = :status")
List<OrderSummary> findSummariesByStatus(@Param("status") OrderStatus status);
}
Spring Data は実行時に OrderSummary を実装するプロキシを作成し、SELECT された 3 つのフィールドのみをマップします。この方法は、サマリーリストを表示する場合にのみ必要なため、エンティティ全体をロードすることを回避します — クエリとメモリの両方の負荷を軽減します。
@Query のテスト:
@DataJpaTest
class OrderRepositoryQueryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindHighValueOrders() {
Order cheap = buildOrder(OrderStatus.PAID, BigDecimal.valueOf(50));
Order expensive = buildOrder(OrderStatus.PAID, BigDecimal.valueOf(500));
orderRepository.saveAll(List.of(cheap, expensive));
List<Order> result = orderRepository.findHighValueOrders(
OrderStatus.PAID, BigDecimal.valueOf(100));
assertThat(result)
.hasSize(1)
.first()
.extracting(Order::getTotalAmount)
.isEqualTo(BigDecimal.valueOf(500));
}
private Order buildOrder(OrderStatus status, BigDecimal amount) {
Order order = new Order();
order.setStatus(status);
order.setTotalAmount(amount);
order.setCustomerEmail("test@test.com");
order.setCreatedAt(LocalDateTime.now());
return order;
}
}
このテストは totalAmount > minAmount 条件が正しく動作することを確認します — 500 の注文のみが返され、50 の注文は除外されます。
6. クエリメソッドと @Query の比較
| 基準 | クエリメソッド | @Query |
|---|---|---|
| 記述速度 | 単純な条件に対して高速 | JPQL/SQL を手動で記述する必要があります |
| サポートされている複雑さ | JOIN、サブクエリで制限あり | 柔軟、すべての JPQL/SQL をサポート |
| 可読性 | 多くの条件がある場合は長い名前 | 明確で、クエリロジックを分離 |
| エラーチェック | コンテキスト起動時のエラー | コンテキスト起動時のエラー(JPQL)、実行時のエラー(ネイティブ SQL) |
| 適している | 基本的な CRUD、1-3 条件 | 複雑なクエリ、JOIN、集計、プロジェクション |
選択の原則:単純な場合はクエリメソッドで開始します。メソッド名が 3-4 の条件を超えるか、JOIN/サブクエリが必要な場合は、読みやすさと制御のために @Query に切り替えます。
7. よくあるエラー
名前付きパラメーターを使用する場合の @Param の忘れ:
// エラー:@Param がない、Spring は :status をどのパラメーターにマップするかわかりません
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusRaw(OrderStatus status);
修正方法:パラメーターに @Param("status") を追加するか、-parameters フラグでコンパイルして Spring がバイトコードからパラメーター名を自動的に取得するようにします(Spring Boot は Maven/Gradle プラグインでこのフラグをデフォルトで有効にしていますが、ビルド構成への依存を避けるために @Param を明示的に記述することをお勧めします)。
クエリを記述する代わりに findAll() を使用して Java ストリームでフィルタリング:
// 非推奨:データベースからアプリケーションにすべてのテーブルをロードしてからフィルタリング
List<Order> paidOrders = orderRepository.findAll().stream()
.filter(o -> o.getStatus() == OrderStatus.PAID)
.toList();
この方法はデータベースからアプリケーションにすべてのデータをロードし、帯域幅とメモリを浪費します。常にフィルタ条件をデータベースに押し下げて、クエリメソッドまたは @Query を通過させます。
トランザクション範囲外で @Modifying を呼び出すときの @Transactional の忘れ:
@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status")
void deleteByStatusBulk(@Param("status") OrderStatus status);
トランザクションで囲まれずにこのメソッドを直接呼び出すと、TransactionRequiredException がスローされます。常にサービス層の @Transactional メソッドでラップしてから実行し、コントローラーから直接呼び出さないでください。
8. まとめ
クエリメソッドは単純なクエリに適しており、条件が 2-3 フィールド以下の場合はコードを短くて読みやすくします。ロジックがより複雑になったとき — JOIN、サブクエリ、バルク更新、またはカスタムプロジェクションが必要な場合 — @Query がより明確で保守しやすい選択肢です。
一般的なルール:Java で処理する代わりに、データフィルタリングロジックをデータベースに押し下げることを優先し、重要なクエリごとにテストを記述し、JPQL が対応できない場合(たとえば、データベース固有の関数が必要な場合)にのみネイティブ SQL の使用を検討します。これら 3 つのテクニックをマスターすれば、実際の Spring Boot アプリケーションでのほとんどのデータ検索タスクに対応するのに十分なツールがあります。