← 開発ノート

Querydsl と JPA Specification:切り替える前にクエリを試す

表現して検証したい処理に応じてクエリツールを選びます。リリース間隔は調査のきっかけになりますが、代替ツールがアプリのクエリ動作を維持する証明にはなりません。

当初の 2023 年の懸念は、その後の Querydsl 5.1.0 リリースより前のものです。タグはその版の存在を示しますが、採用する系列のリポジトリ、アーティファクト座標、互換性は別途評価します。以下は再現環境を固定した例で、現在の本番向けバージョンの推奨ではありません。

代表的な述語を比較する

ツールを変更する前にクエリの結果を比較する

同じ五行を投入し、両方の述語を構築して明示した期待結果を確認します。テスト対象は H2 上のこのエンティティです。移行判断には、実際のアプリで使う結合、射影、ページング、データベース動作も必要です。

Spring Data JPA Specification は Criteria の述語を組み合わせ、JpaSpecificationExecutor で実行します。単純なリポジトリの絞り込みなら、JPA の永続化モデルを置き換えずに対応できる場合があります。

Querydsl の例に三ファイルを追加する

完全な Jakarta Querydsl プロジェクトの五ファイルすべてを出発点とし、Employee.getName() も含めます。再現環境は Java17、Gradle8.4、Boot3.5.7、Querydsl5.1.0 Jakarta、H2 です。以下の完全な三ファイルを追加します。この設定とガイド内の別の Boot4 例を混ぜないでください。

src/main/java/example/EmployeeRepository.java

package example;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface EmployeeRepository extends JpaRepository<Employee, Long>,
        JpaSpecificationExecutor<Employee> {}

src/main/java/example/EmployeeSpecs.java

package example;

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

public final class EmployeeSpecs {
    private EmployeeSpecs() {}

    public static Specification<Employee> named(String name) {
        if (name == null) throw new IllegalArgumentException("Use withoutName for null");
        return (root, query, builder) -> builder.equal(root.get("name"), name);
    }

    public static Specification<Employee> withoutName() {
        return (root, query, builder) -> builder.isNull(root.get("name"));
    }
}

src/test/java/example/QueryStrategyTest.java

package example;

import java.util.Arrays;
import java.util.List;
import com.querydsl.core.types.Predicate;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;

import static org.assertj.core.api.Assertions.*;

@DataJpaTest
class QueryStrategyTest {
    @Autowired TestEntityManager entityManager;
    @Autowired EmployeeRepository employees;
    final QEmployee employee = QEmployee.employee;

    @BeforeEach
    void seed() {
        for (String name : Arrays.asList("Alice", "Bob", "Alice", "A_%", null)) {
            entityManager.persist(new Employee(name));
        }
        entityManager.flush();
    }

    void equivalent(Predicate predicate, Specification<Employee> specification,
                    List<String> expected) {
        var querydsl = new JPAQueryFactory(entityManager.getEntityManager())
                .select(employee.name).from(employee).where(predicate)
                .orderBy(employee.name.asc()).fetch();
        var criteria = employees.findAll(specification, Sort.by("name"))
                .stream().map(Employee::getName).toList();
        assertThat(querydsl).containsExactlyElementsOf(expected);
        assertThat(criteria).containsExactlyElementsOf(expected);
    }

    @Test
    void exactEqualityPreservesDuplicates() {
        equivalent(employee.name.eq("Alice"), EmployeeSpecs.named("Alice"),
                List.of("Alice", "Alice"));
    }

    @Test
    void missingValueReturnsNoRows() {
        equivalent(employee.name.eq("Missing"), EmployeeSpecs.named("Missing"), List.of());
    }

    @Test
    void wildcardCharactersAreLiteralForEquality() {
        equivalent(employee.name.eq("A_%"), EmployeeSpecs.named("A_%"), List.of("A_%"));
    }

    @Test
    void andCanEliminateAllRows() {
        equivalent(employee.name.eq("Alice").and(employee.name.eq("Bob")),
                EmployeeSpecs.named("Alice").and(EmployeeSpecs.named("Bob")), List.of());
    }

    @Test
    void orKeepsMatchesAndDuplicatesInOrder() {
        equivalent(employee.name.eq("Alice").or(employee.name.eq("Bob")),
                EmployeeSpecs.named("Alice").or(EmployeeSpecs.named("Bob")),
                List.of("Alice", "Alice", "Bob"));
    }

    @Test
    void nullRequiresAnExplicitPredicate() {
        equivalent(employee.name.isNull(), EmployeeSpecs.withoutName(),
                Arrays.asList((String) null));
    }

    @Test
    void stringAttributeTypoFailsAtRuntime() {
        Specification<Employee> typo = (root, query, builder) ->
                builder.equal(root.get("missingName"), "Alice");
        assertThatThrownBy(() -> employees.findAll(typo))
                .isInstanceOf(InvalidDataAccessApiUsageException.class);
    }

    @Test
    void equalityHelpersRejectNull() {
        assertThatThrownBy(() -> employee.name.eq((String) null))
                .isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> EmployeeSpecs.named(null))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

JDK17 を選択し、プロジェクトのルートで実行します。

gradle --version
gradle --no-daemon clean test

テストは元の Querydsl の二件に、比較と契約の八件を加えた計十件です。結果を比較する六件では、両実装が独立に指定したリストと一致する必要があり、相互の一致だけでは不十分です。clean test を繰り返し、Q 型の再生成も確認します。

ケース 期待結果
名前が Alice Alice が二件。重複を維持
存在しない名前 行なし
名前が A_% LIKE パターンではなくそのリテラル値
Alice AND Bob 行なし
Alice OR Bob 名前順で Alice、Alice、Bob
明示的な null 述語 null の名前が一件

異なる契約を維持する

Querydsl が生成する employee.name は Java のメンバーです。この Specification 例の root.get("name") は文字列属性を使うため、意図的な誤記 missingName はコンパイルを通り、リポジトリの実行時に失敗します。Specification が必ず文字列を使うという意味ではなく、JPA の静的メタモデルも選べます。

この例の補助メソッド named は null を明示的に拒否し、Querydsl の等値呼び出しも拒否します。null 値には別の述語 withoutName()/isNull() を使います。これは例の入力方針です。実際の移行で null を無断でフィルター省略に変えないでください。

ツール 評価する点
Querydsl JPA 生成する Q 型、JPA クエリの形、プロセッサー互換性
JPA Specification 再利用可能な Criteria フィルターとリポジトリ統合
jOOQ SQL 中心の DSL とスキーマモデリング。生成は任意
Jdbi JDBC クエリと結果のマッピング
Doma コンパイル時処理、SQL テンプレート、Criteria API

これらのツールが担う層は異なります。このテストは jOOQ、Jdbi、Doma の比較、性能測定、本番データベースとの同等性確認をしていません。変更前に実際の結合、順序、ページング、null 方針、トランザクションのケースを加え、選択するデータベースとアーティファクトのライセンス条件を確認してください。

Spring Boot 学習ガイドは、プロジェクト作成、永続化の確認、HTTP テスト、パッケージ化をつなぎます。各記事がそれぞれ再現用のバージョンを記録しています。

コメント

GitHubで見る ↗