Spring Data JPAを使ったページングのカウント処理をカスタマイズする。

## この記事の目的
SpringDataJPAを用いると、ページング処理が簡単に実装できるので便利です。

しかし、親子エンティティー（親1対子Nの構造、ヘッダーディティールと言ったりもする）を一緒にFetchする場合には、本来は親の件数を数えたいのに、子の件数に引っ張られてカウントが正しくできないことがありえます。
この記事では、そのような場合のカスタマイズ方法について紹介します

## 状況の説明

### エンティティーの構造

請求エンティティー１件に対して、複数の支払が紐づく場合があり得るとします。（複数の支払い手段の併用などのケース）

### 問題となるケース

やりたいこととしては、期日がすぎても支払が未完了（未収状態/未払状態）の請求を抽出したいとします。
当然複数の請求が返却されるため、ページングの処理が必要になります。
ページ数を計算する際に必要なのは、1ページの件数と、結果セットの総件数です。

SpringDataJPAを使うとページング処理はよしなにやってくれるので便利ですが、総件数が子供エンティティ（この場合「支払」）に引っ張られて正しい件数が算出されない場合があります。

### 実際のクエリー（件数カウント）

標準の実装では、以下のようなクエリーが生成されます。
```sql
select
    count(b1_0.billing_id) 
from
    billing b1_0 
left join
    payment p1_0 
        on b1_0.billing_id=p1_0.billing_id 
where
    b1_0.payment_due_date<=?
    and (
        p1_0.payment_id is null 
        or (
            select
                sum(p2_0.payment_amount) 
            from
                payment p2_0 
            where
                p2_0.billing_id=b1_0.billing_id
        )<b1_0.billing_amount
    );
```

期待されるクエリー

```sql
select
    count(distinct b1_0.billing_id) 
from
    billing b1_0 
left join
    payment p1_0 
        on b1_0.billing_id=p1_0.billing_id 
where
    b1_0.payment_due_date<=? 
    and (
        p1_0.payment_id is null 
        or (
            select
                sum(p2_0.payment_amount) 
            from
                payment p2_0 
            where
                p2_0.billing_id=b1_0.billing_id
        )<b1_0.billing_amount
    );

```

## 対応方法

いくつかの対応方法がありますが、今回はカスタムのRepositoryを実装する方法により対応しました。

### CustomRepositoryのInterface定義

```java:CustomBillingRepository.java
package org.example.domain.repository;

import java.time.LocalDate;

import org.immutable.domain.entity.Billing;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface CustomBillingRepository {
    Page<Billing> findUnpaidBillings(LocalDate currentDate, Pageable pageable);
}

```

### 上記インタフェースの実装（ここにクエリーを書きます）

```java:CustomBillingRepositoryImpl.java
package org.example.domain.repository;

import java.time.LocalDate;
import java.util.List;

import org.immutable.domain.entity.Billing;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;

public class CustomBillingRepositoryImpl implements CustomBillingRepository {

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public Page<Billing> findUnpaidBillings(LocalDate currentDate, Pageable pageable) {
    
        // メインクエリー
        TypedQuery<Billing> query = entityManager.createQuery(
            "SELECT b FROM Billing b " +
            "LEFT JOIN Payment p ON b.id = p.billing.id " +
            "WHERE b.paymentDueDate <= :currentDate " +
            "AND (p IS NULL OR (SELECT SUM(p.paymentAmount) FROM Payment p WHERE p.billing.id = b.id) < b.billingAmount)", Billing.class);
        query.setParameter("currentDate", currentDate);
        query.setFirstResult((int) pageable.getOffset());
        query.setMaxResults(pageable.getPageSize());
        List<Billing> billings = query.getResultList();

        // カウントクエリー
        TypedQuery<Long> countQuery = entityManager.createQuery(
            "SELECT COUNT(DISTINCT b) FROM Billing b " +
            "LEFT JOIN Payment p ON b.id = p.billing.id " +
            "WHERE b.paymentDueDate <= :currentDate " +
            "AND (p IS NULL OR (SELECT SUM(p.paymentAmount) FROM Payment p WHERE p.billing.id = b.id) < b.billingAmount)", Long.class);
        countQuery.setParameter("currentDate", currentDate);
        Long total = countQuery.getSingleResult();

        return new PageImpl<>(billings, pageable, total);
            
    }
    
}


```

### CustomRepositoryを使ったBillingRepository

上記のインタフェースをextendsするだけです。

```java:BillingRepository.java
package org.immutable.domain.repository;

import org.immutable.domain.entity.Billing;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BillingRepository extends JpaRepository<Billing, Long>, CustomBillingRepository {
    
}
```

### サービスクラスなどからの利用例

```java
    /**
     * 支払い期日が到来しているのに未払いの請求を取得する.
     * @param currentDate 現在日付
     * @param pageable ページング情報
     * @return 未払いの請求のページ
     */
    public Page<Billing> getUnpaidBillings(LocalDate currentDate, Pageable pageable) {

        return billingRepository.findUnpaidBillings(currentDate, pageable);

    }
```

## 備考
この処理の実装については、ChatGPT（GPT-4o）に質問しつつ、ドキュメントを随時参照する方法をとりました。
GPTのおかげでスムーズに問題解決ができました。