はじめに

Spring Data JPAのSpecificationを利用した実装例を作りました。どのように利用するか、どのあたりが便利かなどを整理したいと思います。結論としては、他のクエリの表現方法に対して柔軟性が高い点やプログラマティックに書ける点がSpecificationの特徴だと考えています。この特徴は検索条件が複雑になりがちな画面に応えるAPIを作る際に有用だと考えています。

なお、頭からリファレンスを読んで理解しているというよりプロジェクトで使いながら使い方を覚えているところもあり、誤った理解をしている箇所などあるかもしれません。

作ったもの

https://github.com/Showichiro/jpa-specification-example

Spring Data JPAにおいてよく見るクエリの実装

動的なクエリを表現できない

いずれのケースも、メソッドとクエリが1対1対応しているという点が特徴であり、この点がネックとなる場合があります。例えば、一覧画面でxxxが検索条件に含まれること「も」yyyが検索条件に含まれること「も」zzzが検索条件に含まれること「も」あるという要件があるとします。画面側の入力に合わせて、SELECT文を変える必要がある場合、今回であれば3条件のありなしで8通りのメソッドを用意することになります。 そういった動的に条件文を変えたいという需要を満たすことができるのがSpecificationになります。

Specification

クエリをプログラムで作成するための条件APIとなります。Criteriaを記述することでwhere句を定義することができます。 https://spring.pleiades.io/spring-data/jpa/docs/current/reference/html/#specifications

JpaSpecificationExecutor を継承することで利用することができます。継承することでfindAllなどのメソッドに対してSpecificationを引数に渡すメソッドが定義されます。

List<T> findAll(Specification<T> spec);

このSpecificationはtoPredicateというメソッドを持ち、このtoPredicateメソッド内でCriteriaを利用するという形になっています。例えば、以下のような実装を与えることができます(documentから引用)。

public class CustomerSpecs {

  public static Specification<Customer> isLongTermCustomer() {
    return (root, query, builder) -> {
      LocalDate date = LocalDate.now().minusYears(2);
      return builder.lessThan(root.get(Customer_.createdAt), date);
    };
  }

  public static Specification<Customer> hasSalesOfMoreThan(MonetaryAmount value) {
    return (root, query, builder) -> {
      // build query here
    };
  }
}

そして、Specificationはメソッドチェーンによって複雑なクエリを表現することができます。具体的には、Specificationはwhereandorというデフォルトメソッドを持ちます。 例えば、上記のCustomerSpecsを利用して、isLongTermCustomerもしくはhasSalesOfMoreThanを満たすデータを全件取ってくる場合は

MonetaryAmount amount = new MonetaryAmount(200.0, Currencies.DOLLAR);
List<Customer> customers = customerRepository.findAll(
  isLongTermCustomer().or(hasSalesOfMoreThan(amount)));

という形で表現できます。このSpecificationを駆使すれば、画面からxxxが渡されたときはxxxを検索条件に含める、yyyが渡されたときはyyyを検索条件に含めるといった動的なクエリを実現することができます。

実際にやってみる

今回の題材

teacherテーブルとstudentテーブルを用意しました。TeacherとStudentは1対多の関係を持ち、studentテーブルはteacher_idというteacherとの紐づけを持っています。 JavaにおけるEntity表現としては以下のような形になります。

package jpa.specification.example.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.Data;

@Entity
@Data
public class Student {
    @Id
    private Long id;
    private String name;
    private Integer age;
}
package jpa.specification.example.entity;

import java.util.List;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import lombok.Data;

@Entity
@Data
public class Teacher {
    @Id
    private Long id;
    private String name;
    private Integer age;
    private String email;
    @OneToMany
    @JoinColumn(name = "teacher_id")
    private List<Student> students = List.of();
}

これに対して、teacherのnameやstudentのageを検索条件にできるというケースを考えてみます。

Specificationの実装

Specificationのbuilderクラスを作りました。nameやageを検索条件にするというロジックはprivateメソッドとして定義しています。 buildFindAllSpecificationではSpecification.andで各条件をメソッドチェーンで組み合わせています。各パラメータをOptionalで受け取ってprivateメソッドのロジックを呼ぶ/呼ばないでパラメータに合わせたSpecificationを生成するようにしています。 join関数はN+1問題を引き起こさないためにjoin fetchしています。

package jpa.specification.example.specification;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Component;

import jakarta.persistence.criteria.JoinType;
import jpa.specification.example.entity.Student;
import jpa.specification.example.entity.Teacher;

@Component
public class TeacherSpecification {
    public Specification<Teacher> buildFindAllSpecification(Optional<String> name, Optional<Integer> studentAge) {
        return Specification.where(join())
                .and(name.map(this::byName).orElse(null))
                .and(studentAge.map(this::greaterThanStudentAge).orElse(null));
    }

    private Specification<Teacher> join() {
        return (root, query, builder) -> {
            root.fetch("students", JoinType.INNER);
            return null;
        };
    }

    private Specification<Teacher> byName(String name) {
        return (root, query, builder) -> {
            return builder.equal(root.get("name"), name);
        };
    }

    private Specification<Teacher> greaterThanStudentAge(Integer age) {
        return (root, query, builder) -> {
            return builder.gt(root.<List<Student>>get("students").get("age"), age);
        };
    }
}

これを呼び出すServiceとControllerは以下の通り実装しました。

package jpa.specification.example.service;

import java.util.List;
import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import jpa.specification.example.entity.Teacher;
import jpa.specification.example.repository.TeacherRepository;
import jpa.specification.example.specification.TeacherSpecification;

@Service
public class TeacherService {
    @Autowired
    private TeacherRepository teacherRepository;

    @Autowired
    private TeacherSpecification teacherSpecification;

    public List<Teacher> findAll(Optional<String> name, Optional<Integer> studentAge) {
        return teacherRepository.findAll(teacherSpecification.buildFindAllSpecification(name, studentAge));
    }

    public List<Teacher> defaultFindAll() {
        return teacherRepository.findAll();
    }
}
package jpa.specification.example.controller;

import java.util.List;
import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import jpa.specification.example.entity.Teacher;
import jpa.specification.example.service.TeacherService;

@RestController
public class TeacherController {
    @Autowired
    private TeacherService teacherService;

    @GetMapping("/teachers")
    public List<Teacher> findAll(@RequestParam(name = "name", required = false) Optional<String> name,
            @RequestParam(name = "studentAge", required = false) Optional<Integer> studentAge) {
        return teacherService.findAll(name, studentAge);
    }
}

叩いてみる

クエリパラメータなし

curl localhost:8080/teachers
[{"id":1,"name":"John","age":30,"email":"john@example.com","students":[{"id":1,"name":"Steve","age":15},{"id":2,"name":"Abby","age":14},{"id":3,"name":"Harvey","age":13},{"id":4,"name":"Ian","age":12}]},{"id":2,"name":"Jane","age":31,"email":"jane@example.com","students":[{"id":5,"name":"Liam","age":15},{"id":6,"name":"Jennifer","age":14},{"id":7,"name":"Hilary","age":13}]}]

生成されたSQL(フォーマットあり)

SELECT
  t1_0.id,
  t1_0.age,
  t1_0.email,
  t1_0.name,
  s1_0.teacher_id,
  s1_0.id,
  s1_0.age,
  s1_0.name
FROM
  teacher t1_0
  JOIN student s1_0 ON t1_0.id = s1_0.teacher_id

name指定あり

curl localhost:8080/teachers?name=John
[{"id":1,"name":"John","age":30,"email":"john@example.com","students":[{"id":1,"name":"Steve","age":15},{"id":2,"name":"Abby","age":14},{"id":3,"name":"Harvey","age":13},{"id":4,"name":"Ian","age":12}]}]

生成されたSQL(フォーマットあり)

SELECT
  t1_0.id,
  t1_0.age,
  t1_0.email,
  t1_0.name,
  s1_0.teacher_id,
  s1_0.id,
  s1_0.age,
  s1_0.name
FROM
  teacher t1_0
  JOIN student s1_0 ON t1_0.id = s1_0.teacher_id
WHERE
  t1_0.name = ?

studentAge指定あり

curl localhost:8080/teachers?studentAge=14
[{"id":1,"name":"John","age":30,"email":"john@example.com","students":[{"id":1,"name":"Steve","age":15}]},{"id":2,"name":"Jane","age":31,"email":"jane@example.com","students":[{"id":5,"name":"Liam","age":15}]}]

生成されたSQL(フォーマットあり)

SELECT
  t1_0.id,
  t1_0.age,
  t1_0.email,
  t1_0.name,
  s1_0.teacher_id,
  s1_0.id,
  s1_0.age,
  s1_0.name
FROM
  teacher t1_0
  JOIN student s1_0 ON t1_0.id = s1_0.teacher_id
WHERE
  s1_0.age > ?

両方指定あり

curl 'localhost:8080/teachers?studentA
ge=14&name=John'
[{"id":1,"name":"John","age":30,"email":"john@example.com","students":[{"id":1,"name":"Steve","age":15}]}]

生成されたSQL(フォーマットあり)

SELECT
  t1_0.id,
  t1_0.age,
  t1_0.email,
  t1_0.name,
  s1_0.teacher_id,
  s1_0.id,
  s1_0.age,
  s1_0.name
FROM
  teacher t1_0
  JOIN student s1_0 ON t1_0.id = s1_0.teacher_id
WHERE
  t1_0.name = ?
  AND s1_0.age > ?

以上のように一つのRepositoryのメソッドから複数のクエリを発行することができました。

終わりに

Spring Data JPAの提供するインターフェース群のうち、Specificationにフォーカスした記事を書きました。CRUDRepositoryやRepositoryなど他のクエリの表現方法は基本的にクエリとメソッドが1対1対応することが特徴でありわかりやすさもある反面、検索条件が複雑に変わる仕様の場合取り回しが難しい場面があります。一方で、Specificationは柔軟にプログラマティックにクエリを書くことができる点が特徴だと考えています。この特徴は検索条件が複雑になりがちな画面に応えるAPIを作る際に有用だと考えています。

記事一覧に戻る