Spring Data JPA Querydslは、タイプセーフなクエリをJavaコードで構築するための強力なフレームワークです。従来のJPQLやネイティブクエリでは文字列ベースで記述するため、コンパイル時にエラーを検出できませんでした。Querydslを導入することで、エンティティの構造変更に追従したコンパイル時検証が可能になり、複雑なJOINクエリ、サブクエリ、動的条件の構築をIDEの補完機能とともに安全に実装できます。本記事では、Querydslの導入手順からJPAQueryFactoryを使った高度なクエリ実装、Projectionによる部分取得、パフォーマンス最適化のポイントまで、実践的な知識を体系的に解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Boot 3.4.xのデフォルト)
Querydsl 5.1.0(Jakarta EE対応版)
データベース H2 Database または PostgreSQL
ビルドツール Maven または Gradle

事前に以下の知識があることを前提とします。

  • Spring Data JPAの基本(Entity定義、JpaRepository)
  • JPQLまたはCriteria APIの基本的な理解
  • Javaのラムダ式とメソッド参照

なぜQuerydslが必要なのか

Spring Data JPAでは、Specificationパターンを使った動的クエリ構築が可能ですが、CriteriaBuilder/CriteriaQuery/RootのAPIは冗長で可読性に欠けます。Querydslは、より直感的で流暢なAPIを提供します。

課題 従来のアプローチ Querydslの解決策
可読性 CriteriaAPIは冗長で理解しにくい SQLライクな流暢なAPIで直感的
タイプセーフ 文字列ベースのJPQLはタイポに脆弱 Qクラスによるコンパイル時検証
IDE補完 プロパティ名を文字列で指定 Qクラスのプロパティで補完が効く
複雑なクエリ JOIN、サブクエリが書きにくい SQLに近い構文で自然に記述可能
保守性 エンティティ変更時に実行時エラー コンパイル時にエラー検出

Querydslは、エンティティクラスからQクラス(メタモデル)を自動生成し、そのQクラスを使ってクエリを組み立てます。これにより、フィールド名のタイプミスやリファクタリング時の参照漏れをコンパイル時に検出できます。

Querydslの導入手順

Mavenでの依存関係設定

Spring Boot 3.x以降はJakarta EE(Jakarta Persistence)を使用するため、jakartaクラシファイアを指定する必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<properties>
    <querydsl.version>5.1.0</querydsl.version>
</properties>

<dependencies>
    <!-- Spring Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Querydsl JPA (Jakarta EE対応) -->
    <dependency>
        <groupId>com.querydsl</groupId>
        <artifactId>querydsl-jpa</artifactId>
        <version>${querydsl.version}</version>
        <classifier>jakarta</classifier>
    </dependency>

    <!-- Querydsl APT (アノテーションプロセッサ) -->
    <dependency>
        <groupId>com.querydsl</groupId>
        <artifactId>querydsl-apt</artifactId>
        <version>${querydsl.version}</version>
        <classifier>jakarta</classifier>
        <scope>provided</scope>
    </dependency>

    <!-- H2 Database -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <annotationProcessorPaths>
                    <annotationProcessorPath>
                        <groupId>com.querydsl</groupId>
                        <artifactId>querydsl-apt</artifactId>
                        <version>${querydsl.version}</version>
                        <classifier>jakarta</classifier>
                    </annotationProcessorPath>
                    <annotationProcessorPath>
                        <groupId>jakarta.persistence</groupId>
                        <artifactId>jakarta.persistence-api</artifactId>
                        <version>3.1.0</version>
                    </annotationProcessorPath>
                </annotationProcessorPaths>
                <generatedSourcesDirectory>target/generated-sources</generatedSourcesDirectory>
            </configuration>
        </plugin>
    </plugins>
</build>

Gradleでの依存関係設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.1'
    id 'io.spring.dependency-management' version '1.1.7'
}

def querydslVersion = '5.1.0'

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    // Querydsl JPA (Jakarta EE対応)
    implementation "com.querydsl:querydsl-jpa:${querydslVersion}:jakarta"
    
    // Querydsl APT (アノテーションプロセッサ)
    annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta"
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api:3.1.0'
    
    runtimeOnly 'com.h2database:h2'
}

// Qクラスの生成先ディレクトリを設定
sourceSets {
    main {
        java {
            srcDirs += 'build/generated/sources/annotationProcessor/java/main'
        }
    }
}

Qクラスの生成

Querydslのアノテーションプロセッサは、@Entityが付与されたクラスからQクラス(Qプレフィックス付きのメタモデルクラス)を自動生成します。

1
2
3
4
5
# Mavenの場合
mvn compile

# Gradleの場合
./gradlew compileJava

コンパイル後、target/generated-sources(Maven)またはbuild/generated/sources/annotationProcessor(Gradle)にQクラスが生成されます。

例えば、以下のProductエンティティからQProductクラスが生成されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.demo.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String category;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ProductStatus status;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(name = "stock_quantity", nullable = false)
    private Integer stockQuantity;

    @Column(name = "release_date")
    private LocalDate releaseDate;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "supplier_id")
    private Supplier supplier;

    // コンストラクタ、getter、setter
    public Product() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public ProductStatus getStatus() { return status; }
    public void setStatus(ProductStatus status) { this.status = status; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
    public LocalDate getReleaseDate() { return releaseDate; }
    public void setReleaseDate(LocalDate releaseDate) { this.releaseDate = releaseDate; }
    public Supplier getSupplier() { return supplier; }
    public void setSupplier(Supplier supplier) { this.supplier = supplier; }
}

生成されるQProductクラスは以下のような構造になります(実際のコードは自動生成されます)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 自動生成されるQProductクラスのイメージ
public class QProduct extends EntityPathBase<Product> {
    public static final QProduct product = new QProduct("product");
    
    public final NumberPath<Long> id = createNumber("id", Long.class);
    public final StringPath name = createString("name");
    public final StringPath category = createString("category");
    public final EnumPath<ProductStatus> status = createEnum("status", ProductStatus.class);
    public final NumberPath<BigDecimal> price = createNumber("price", BigDecimal.class);
    public final NumberPath<Integer> stockQuantity = createNumber("stockQuantity", Integer.class);
    public final DatePath<LocalDate> releaseDate = createDate("releaseDate", LocalDate.class);
    public final QSupplier supplier;
    // ...
}

QuerydslPredicateExecutorの使い方

Spring Data JPAはQuerydslPredicateExecutorインターフェースを提供しており、リポジトリに継承させることで簡単にQuerydslを使用できます。

リポジトリの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package com.example.demo.repository;

import com.example.demo.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.stereotype.Repository;

@Repository
public interface ProductRepository extends 
        JpaRepository<Product, Long>, 
        QuerydslPredicateExecutor<Product> {
}

基本的なクエリ実行

QuerydslPredicateExecutorが提供するメソッドを使用して、Predicateベースのクエリを実行できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package com.example.demo.service;

import com.example.demo.entity.Product;
import com.example.demo.entity.ProductStatus;
import com.example.demo.entity.QProduct;
import com.example.demo.repository.ProductRepository;
import com.querydsl.core.types.Predicate;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.Optional;

@Service
@Transactional(readOnly = true)
public class ProductQueryService {

    private final ProductRepository productRepository;
    private final QProduct qProduct = QProduct.product;

    public ProductQueryService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    /**
     * 名前に指定文字列を含む商品を検索
     */
    public Iterable<Product> findByNameContaining(String keyword) {
        Predicate predicate = qProduct.name.containsIgnoreCase(keyword);
        return productRepository.findAll(predicate);
    }

    /**
     * 複数条件での検索(カテゴリ、ステータス、価格範囲)
     */
    public Page<Product> findByConditions(
            String category,
            ProductStatus status,
            BigDecimal minPrice,
            BigDecimal maxPrice,
            Pageable pageable) {

        Predicate predicate = qProduct.category.eq(category)
                .and(qProduct.status.eq(status))
                .and(qProduct.price.between(minPrice, maxPrice));

        return productRepository.findAll(predicate, pageable);
    }

    /**
     * 単一の商品を取得
     */
    public Optional<Product> findActiveProductById(Long id) {
        Predicate predicate = qProduct.id.eq(id)
                .and(qProduct.status.eq(ProductStatus.ACTIVE));

        return productRepository.findOne(predicate);
    }

    /**
     * 条件に一致する商品数をカウント
     */
    public long countByCategory(String category) {
        Predicate predicate = qProduct.category.eq(category);
        return productRepository.count(predicate);
    }

    /**
     * 条件に一致する商品が存在するか確認
     */
    public boolean existsInStock(String category) {
        Predicate predicate = qProduct.category.eq(category)
                .and(qProduct.stockQuantity.gt(0));
        return productRepository.exists(predicate);
    }
}

BooleanBuilderによる動的条件構築

検索フォームのように、条件がnullの場合は適用しないといった動的なクエリを構築する場合はBooleanBuilderを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package com.example.demo.service;

import com.example.demo.entity.Product;
import com.example.demo.entity.ProductStatus;
import com.example.demo.entity.QProduct;
import com.example.demo.repository.ProductRepository;
import com.querydsl.core.BooleanBuilder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.math.BigDecimal;

@Service
@Transactional(readOnly = true)
public class ProductSearchService {

    private final ProductRepository productRepository;
    private final QProduct qProduct = QProduct.product;

    public ProductSearchService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    /**
     * 動的検索(nullの条件は適用しない)
     */
    public Page<Product> searchProducts(
            String keyword,
            String category,
            ProductStatus status,
            BigDecimal minPrice,
            BigDecimal maxPrice,
            Boolean inStockOnly,
            Pageable pageable) {

        BooleanBuilder builder = new BooleanBuilder();

        // キーワード検索(名前に含まれる)
        if (StringUtils.hasText(keyword)) {
            builder.and(qProduct.name.containsIgnoreCase(keyword));
        }

        // カテゴリ指定
        if (StringUtils.hasText(category)) {
            builder.and(qProduct.category.eq(category));
        }

        // ステータス指定
        if (status != null) {
            builder.and(qProduct.status.eq(status));
        }

        // 最低価格
        if (minPrice != null) {
            builder.and(qProduct.price.goe(minPrice));
        }

        // 最高価格
        if (maxPrice != null) {
            builder.and(qProduct.price.loe(maxPrice));
        }

        // 在庫ありのみ
        if (Boolean.TRUE.equals(inStockOnly)) {
            builder.and(qProduct.stockQuantity.gt(0));
        }

        return productRepository.findAll(builder.getValue(), pageable);
    }
}

JPAQueryFactoryによる高度なクエリ

QuerydslPredicateExecutorは基本的なクエリには便利ですが、複雑なJOIN、サブクエリ、Projectionを使用する場合はJPAQueryFactoryを直接使用します。

JPAQueryFactoryの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.example.demo.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

関連エンティティの定義

複雑なクエリの例を示すために、追加のエンティティを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.demo.entity;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "suppliers")
public class Supplier {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String country;

    @Column(nullable = false)
    private String contactEmail;

    @OneToMany(mappedBy = "supplier", cascade = CascadeType.ALL)
    private List<Product> products = new ArrayList<>();

    // コンストラクタ、getter、setter
    public Supplier() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getCountry() { return country; }
    public void setCountry(String country) { this.country = country; }
    public String getContactEmail() { return contactEmail; }
    public void setContactEmail(String contactEmail) { this.contactEmail = contactEmail; }
    public List<Product> getProducts() { return products; }
    public void setProducts(List<Product> products) { this.products = products; }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example.demo.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @Column(nullable = false)
    private Integer quantity;

    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal totalAmount;

    @Column(name = "ordered_at", nullable = false)
    private LocalDateTime orderedAt;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderStatus status;

    // コンストラクタ、getter、setter
    public Order() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public Product getProduct() { return product; }
    public void setProduct(Product product) { this.product = product; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
    public LocalDateTime getOrderedAt() { return orderedAt; }
    public void setOrderedAt(LocalDateTime orderedAt) { this.orderedAt = orderedAt; }
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
}
1
2
3
4
5
6
7
8
9
package com.example.demo.entity;

public enum ProductStatus {
    ACTIVE, INACTIVE, DISCONTINUED
}

public enum OrderStatus {
    PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}

JOINクエリの実装

JPAQueryFactoryを使用したJOINクエリの例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package com.example.demo.repository;

import com.example.demo.entity.*;
import com.querydsl.core.Tuple;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.List;

@Repository
public class ProductQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QProduct qProduct = QProduct.product;
    private final QSupplier qSupplier = QSupplier.supplier;
    private final QOrder qOrder = QOrder.order;

    public ProductQueryRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    /**
     * INNER JOIN: サプライヤー情報と共に商品を取得
     */
    public List<Product> findProductsWithSupplier(String country) {
        return queryFactory
                .selectFrom(qProduct)
                .innerJoin(qProduct.supplier, qSupplier).fetchJoin()
                .where(qSupplier.country.eq(country))
                .fetch();
    }

    /**
     * LEFT JOIN: サプライヤーがなくても商品を取得
     */
    public List<Product> findAllProductsWithOptionalSupplier() {
        return queryFactory
                .selectFrom(qProduct)
                .leftJoin(qProduct.supplier, qSupplier).fetchJoin()
                .orderBy(qProduct.name.asc())
                .fetch();
    }

    /**
     * 複数のJOIN: 商品、サプライヤー、注文を結合
     */
    public List<Tuple> findProductOrderSummary() {
        return queryFactory
                .select(
                        qProduct.name,
                        qSupplier.name,
                        qOrder.quantity.sum(),
                        qOrder.totalAmount.sum()
                )
                .from(qProduct)
                .innerJoin(qProduct.supplier, qSupplier)
                .innerJoin(qOrder).on(qOrder.product.eq(qProduct))
                .where(qOrder.status.eq(OrderStatus.DELIVERED))
                .groupBy(qProduct.id, qProduct.name, qSupplier.name)
                .orderBy(qOrder.totalAmount.sum().desc())
                .fetch();
    }

    /**
     * Fetch JoinでN+1問題を回避
     */
    public List<Supplier> findSuppliersWithProducts() {
        return queryFactory
                .selectFrom(qSupplier)
                .leftJoin(qSupplier.products, qProduct).fetchJoin()
                .where(qProduct.status.eq(ProductStatus.ACTIVE))
                .distinct()
                .fetch();
    }
}

サブクエリの実装

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
package com.example.demo.repository;

import com.example.demo.entity.*;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;

@Repository
public class ProductSubqueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QProduct qProduct = QProduct.product;
    private final QOrder qOrder = QOrder.order;
    private final QSupplier qSupplier = QSupplier.supplier;

    public ProductSubqueryRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    /**
     * サブクエリ: カテゴリ内で平均価格以上の商品を取得
     */
    public List<Product> findProductsAboveAveragePrice(String category) {
        QProduct subProduct = new QProduct("subProduct");

        return queryFactory
                .selectFrom(qProduct)
                .where(qProduct.category.eq(category)
                        .and(qProduct.price.goe(
                                JPAExpressions
                                        .select(subProduct.price.avg())
                                        .from(subProduct)
                                        .where(subProduct.category.eq(category))
                        )))
                .orderBy(qProduct.price.desc())
                .fetch();
    }

    /**
     * EXISTS サブクエリ: 注文が存在する商品のみ取得
     */
    public List<Product> findProductsWithOrders() {
        return queryFactory
                .selectFrom(qProduct)
                .where(JPAExpressions
                        .selectOne()
                        .from(qOrder)
                        .where(qOrder.product.eq(qProduct))
                        .exists())
                .fetch();
    }

    /**
     * NOT EXISTS サブクエリ: 注文がない商品を取得
     */
    public List<Product> findProductsWithoutOrders() {
        return queryFactory
                .selectFrom(qProduct)
                .where(JPAExpressions
                        .selectOne()
                        .from(qOrder)
                        .where(qOrder.product.eq(qProduct))
                        .notExists())
                .fetch();
    }

    /**
     * IN サブクエリ: 特定期間に注文された商品
     */
    public List<Product> findOrderedProductsInPeriod(
            LocalDateTime startDate, 
            LocalDateTime endDate) {
        
        return queryFactory
                .selectFrom(qProduct)
                .where(qProduct.id.in(
                        JPAExpressions
                                .select(qOrder.product.id)
                                .from(qOrder)
                                .where(qOrder.orderedAt.between(startDate, endDate))
                ))
                .fetch();
    }

    /**
     * 相関サブクエリ: 各サプライヤーの最高価格商品
     */
    public List<Product> findMostExpensiveProductBySupplier() {
        QProduct subProduct = new QProduct("subProduct");

        return queryFactory
                .selectFrom(qProduct)
                .where(qProduct.price.eq(
                        JPAExpressions
                                .select(subProduct.price.max())
                                .from(subProduct)
                                .where(subProduct.supplier.eq(qProduct.supplier))
                ))
                .fetch();
    }
}

動的ソートの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package com.example.demo.repository;

import com.example.demo.entity.Product;
import com.example.demo.entity.QProduct;
import com.querydsl.core.types.Order;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.List;

@Repository
public class ProductSortingRepository {

    private final JPAQueryFactory queryFactory;
    private final QProduct qProduct = QProduct.product;

    public ProductSortingRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    /**
     * Pageableを使用した動的ソートとページング
     */
    public Page<Product> findAllWithDynamicSort(Pageable pageable) {
        JPAQuery<Product> query = queryFactory
                .selectFrom(qProduct)
                .where(qProduct.status.eq(com.example.demo.entity.ProductStatus.ACTIVE));

        // ソート条件を適用
        for (Sort.Order order : pageable.getSort()) {
            PathBuilder<Product> pathBuilder = new PathBuilder<>(Product.class, "product");
            query.orderBy(new OrderSpecifier<>(
                    order.isAscending() ? Order.ASC : Order.DESC,
                    pathBuilder.get(order.getProperty(), Comparable.class)
            ));
        }

        // トータル件数を取得
        long total = query.fetchCount();

        // ページング適用
        List<Product> content = query
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

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

    /**
     * 複数条件でのソート(固定)
     */
    public List<Product> findAllSortedByPriceAndName() {
        return queryFactory
                .selectFrom(qProduct)
                .orderBy(
                        qProduct.price.desc(),
                        qProduct.name.asc()
                )
                .fetch();
    }

    /**
     * NULL値の扱いを指定したソート
     */
    public List<Product> findAllSortedWithNullsLast() {
        return queryFactory
                .selectFrom(qProduct)
                .orderBy(
                        qProduct.releaseDate.desc().nullsLast(),
                        qProduct.name.asc()
                )
                .fetch();
    }

    /**
     * 条件付きソート(ケース式)
     */
    @SuppressWarnings("unchecked")
    public List<Product> findAllWithConditionalSort() {
        return queryFactory
                .selectFrom(qProduct)
                .orderBy(
                        qProduct.status.when(com.example.demo.entity.ProductStatus.ACTIVE).then(1)
                                .when(com.example.demo.entity.ProductStatus.INACTIVE).then(2)
                                .otherwise(3).asc(),
                        qProduct.name.asc()
                )
                .fetch();
    }
}

ProjectionによるDTO部分取得

エンティティ全体ではなく、必要なフィールドのみを取得することで、パフォーマンスを向上させることができます。

DTOクラスの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.example.demo.dto;

import java.math.BigDecimal;

public class ProductSummaryDto {
    
    private final Long id;
    private final String name;
    private final String category;
    private final BigDecimal price;
    private final String supplierName;

    public ProductSummaryDto(Long id, String name, String category, 
                             BigDecimal price, String supplierName) {
        this.id = id;
        this.name = name;
        this.category = category;
        this.price = price;
        this.supplierName = supplierName;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public String getCategory() { return category; }
    public BigDecimal getPrice() { return price; }
    public String getSupplierName() { return supplierName; }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.demo.dto;

import java.math.BigDecimal;

public class ProductStatisticsDto {
    
    private final String category;
    private final Long productCount;
    private final BigDecimal totalValue;
    private final BigDecimal averagePrice;
    private final BigDecimal maxPrice;
    private final BigDecimal minPrice;

    public ProductStatisticsDto(String category, Long productCount, 
                                BigDecimal totalValue, BigDecimal averagePrice,
                                BigDecimal maxPrice, BigDecimal minPrice) {
        this.category = category;
        this.productCount = productCount;
        this.totalValue = totalValue;
        this.averagePrice = averagePrice;
        this.maxPrice = maxPrice;
        this.minPrice = minPrice;
    }

    public String getCategory() { return category; }
    public Long getProductCount() { return productCount; }
    public BigDecimal getTotalValue() { return totalValue; }
    public BigDecimal getAveragePrice() { return averagePrice; }
    public BigDecimal getMaxPrice() { return maxPrice; }
    public BigDecimal getMinPrice() { return minPrice; }
}

Projectionの実装

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
package com.example.demo.repository;

import com.example.demo.dto.ProductStatisticsDto;
import com.example.demo.dto.ProductSummaryDto;
import com.example.demo.entity.*;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class ProductProjectionRepository {

    private final JPAQueryFactory queryFactory;
    private final QProduct qProduct = QProduct.product;
    private final QSupplier qSupplier = QSupplier.supplier;

    public ProductProjectionRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    /**
     * コンストラクタベースのProjection
     */
    public List<ProductSummaryDto> findProductSummaries() {
        return queryFactory
                .select(Projections.constructor(
                        ProductSummaryDto.class,
                        qProduct.id,
                        qProduct.name,
                        qProduct.category,
                        qProduct.price,
                        qSupplier.name
                ))
                .from(qProduct)
                .leftJoin(qProduct.supplier, qSupplier)
                .where(qProduct.status.eq(ProductStatus.ACTIVE))
                .orderBy(qProduct.name.asc())
                .fetch();
    }

    /**
     * フィールド名ベースのProjection(Beanプロパティ)
     */
    public List<ProductSummaryDto> findProductSummariesByBean() {
        return queryFactory
                .select(Projections.bean(
                        ProductSummaryDto.class,
                        qProduct.id,
                        qProduct.name,
                        qProduct.category,
                        qProduct.price,
                        qSupplier.name.as("supplierName")
                ))
                .from(qProduct)
                .leftJoin(qProduct.supplier, qSupplier)
                .fetch();
    }

    /**
     * 集計クエリとProjection
     */
    public List<ProductStatisticsDto> findCategoryStatistics() {
        return queryFactory
                .select(Projections.constructor(
                        ProductStatisticsDto.class,
                        qProduct.category,
                        qProduct.count(),
                        qProduct.price.multiply(qProduct.stockQuantity).sum(),
                        qProduct.price.avg(),
                        qProduct.price.max(),
                        qProduct.price.min()
                ))
                .from(qProduct)
                .where(qProduct.status.eq(ProductStatus.ACTIVE))
                .groupBy(qProduct.category)
                .having(qProduct.count().gt(0))
                .orderBy(qProduct.category.asc())
                .fetch();
    }

    /**
     * Tuple形式での取得(型付けなし)
     */
    public List<com.querydsl.core.Tuple> findProductTuples() {
        return queryFactory
                .select(
                        qProduct.id,
                        qProduct.name,
                        qProduct.price,
                        qProduct.stockQuantity,
                        qProduct.price.multiply(qProduct.stockQuantity).as("totalValue")
                )
                .from(qProduct)
                .fetch();
    }

    /**
     * 条件式を含むProjection
     */
    public List<ProductSummaryDto> findProductsWithConditionalFields() {
        return queryFactory
                .select(Projections.constructor(
                        ProductSummaryDto.class,
                        qProduct.id,
                        qProduct.name,
                        qProduct.category,
                        qProduct.price,
                        Expressions.cases()
                                .when(qSupplier.name.isNotNull())
                                .then(qSupplier.name)
                                .otherwise("Unknown Supplier")
                ))
                .from(qProduct)
                .leftJoin(qProduct.supplier, qSupplier)
                .fetch();
    }
}

パフォーマンス最適化のポイント

Querydslを効果的に使用するためのパフォーマンス最適化のポイントを解説します。

N+1問題の回避

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package com.example.demo.repository;

import com.example.demo.entity.*;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class OptimizedQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QProduct qProduct = QProduct.product;
    private final QSupplier qSupplier = QSupplier.supplier;

    public OptimizedQueryRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    /**
     * 悪い例: N+1問題が発生する
     * 各Productに対してSupplierを個別に取得してしまう
     */
    public List<Product> findProductsBad() {
        return queryFactory
                .selectFrom(qProduct)
                .fetch();
        // この後 product.getSupplier().getName() を呼ぶと
        // Productの数だけSupplierへのクエリが発生する
    }

    /**
     * 良い例: Fetch Joinで一度に取得
     */
    public List<Product> findProductsGood() {
        return queryFactory
                .selectFrom(qProduct)
                .leftJoin(qProduct.supplier, qSupplier).fetchJoin()
                .fetch();
    }

    /**
     * 良い例: Projectionで必要なデータのみ取得
     */
    public List<com.querydsl.core.Tuple> findProductProjection() {
        return queryFactory
                .select(
                        qProduct.id,
                        qProduct.name,
                        qSupplier.name
                )
                .from(qProduct)
                .leftJoin(qProduct.supplier, qSupplier)
                .fetch();
    }
}

バッチ処理での最適化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.example.demo.repository;

import com.example.demo.entity.*;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

@Repository
public class BatchQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final EntityManager entityManager;
    private final QProduct qProduct = QProduct.product;

    public BatchQueryRepository(JPAQueryFactory queryFactory, 
                                EntityManager entityManager) {
        this.queryFactory = queryFactory;
        this.entityManager = entityManager;
    }

    /**
     * 一括更新(UPDATE文の直接実行)
     */
    @Transactional
    public long updatePricesByCategory(String category, BigDecimal multiplier) {
        return queryFactory
                .update(qProduct)
                .set(qProduct.price, qProduct.price.multiply(multiplier))
                .where(qProduct.category.eq(category))
                .execute();
    }

    /**
     * 一括削除(DELETE文の直接実行)
     */
    @Transactional
    public long deleteDiscontinuedProducts() {
        return queryFactory
                .delete(qProduct)
                .where(qProduct.status.eq(ProductStatus.DISCONTINUED))
                .execute();
    }

    /**
     * ストリーミング処理(大量データの効率的な処理)
     */
    @Transactional(readOnly = true)
    public void processAllProductsStreaming() {
        try (var stream = queryFactory
                .selectFrom(qProduct)
                .where(qProduct.status.eq(ProductStatus.ACTIVE))
                .stream()) {
            
            stream.forEach(product -> {
                // 各商品を処理
                processProduct(product);
                
                // 定期的にキャッシュをクリア(メモリ対策)
                entityManager.detach(product);
            });
        }
    }

    private void processProduct(Product product) {
        // 商品ごとの処理ロジック
    }

    /**
     * ページング処理での最適化
     */
    public List<Product> findProductsPaged(int page, int size) {
        return queryFactory
                .selectFrom(qProduct)
                .where(qProduct.status.eq(ProductStatus.ACTIVE))
                .orderBy(qProduct.id.asc())
                .offset((long) page * size)
                .limit(size)
                .fetch();
    }
}

インデックスを意識したクエリ設計

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.example.demo.repository;

import com.example.demo.entity.*;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;

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

@Repository
public class IndexAwareQueryRepository {

    private final JPAQueryFactory queryFactory;
    private final QProduct qProduct = QProduct.product;

    public IndexAwareQueryRepository(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    /**
     * 良い例: インデックスが効く条件から記述
     * categoryにインデックスがある場合、最初に絞り込む
     */
    public List<Product> findByConditionsOptimized(
            String category, 
            ProductStatus status,
            LocalDate releaseDateFrom) {
        
        return queryFactory
                .selectFrom(qProduct)
                .where(
                        // インデックスカラムを先に
                        qProduct.category.eq(category),
                        qProduct.status.eq(status),
                        qProduct.releaseDate.goe(releaseDateFrom)
                )
                .orderBy(qProduct.releaseDate.desc())
                .fetch();
    }

    /**
     * 避けるべき例: LIKE '%keyword%' は全文検索になる
     */
    public List<Product> findByNameContaining(String keyword) {
        // contains() は LIKE '%keyword%' となりインデックスが効かない
        return queryFactory
                .selectFrom(qProduct)
                .where(qProduct.name.containsIgnoreCase(keyword))
                .fetch();
    }

    /**
     * 良い例: 前方一致ならインデックスが効く
     */
    public List<Product> findByNameStartingWith(String prefix) {
        // startsWith() は LIKE 'prefix%' となりインデックスが効く
        return queryFactory
                .selectFrom(qProduct)
                .where(qProduct.name.startsWithIgnoreCase(prefix))
                .fetch();
    }
}

よくある誤解とアンチパターン

アンチパターン1: エンティティをProjection代わりに使う

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 悪い例: 不要なフィールドも全て取得
public List<Product> findAllProducts() {
    return queryFactory.selectFrom(qProduct).fetch();
}

// 良い例: 必要なフィールドのみProjectionで取得
public List<ProductSummaryDto> findProductSummaries() {
    return queryFactory
            .select(Projections.constructor(
                    ProductSummaryDto.class,
                    qProduct.id,
                    qProduct.name,
                    qProduct.price
            ))
            .from(qProduct)
            .fetch();
}

アンチパターン2: ループ内でクエリを実行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 悪い例: N回のクエリが発生
public void processCategories(List<String> categories) {
    for (String category : categories) {
        List<Product> products = queryFactory
                .selectFrom(qProduct)
                .where(qProduct.category.eq(category))
                .fetch();
        process(products);
    }
}

// 良い例: 1回のクエリで全て取得
public void processCategoriesOptimized(List<String> categories) {
    List<Product> products = queryFactory
            .selectFrom(qProduct)
            .where(qProduct.category.in(categories))
            .fetch();
    
    // カテゴリごとにグルーピング
    products.stream()
            .collect(Collectors.groupingBy(Product::getCategory))
            .forEach((category, productList) -> process(productList));
}

アンチパターン3: 不適切なFetch Join

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 悪い例: コレクションの複数Fetch Joinは結果が膨張する
public List<Supplier> findSuppliersWithProductsAndOrders() {
    QOrder qOrder = QOrder.order;
    // products と orders の両方をFetch Joinすると
    // 結果がデカルト積となり、重複データが大量発生
    return queryFactory
            .selectFrom(qSupplier)
            .leftJoin(qSupplier.products, qProduct).fetchJoin()
            // .leftJoin(qProduct.orders, qOrder).fetchJoin() // 危険!
            .distinct()
            .fetch();
}

// 良い例: 複数のコレクションは別々のクエリで取得
public Supplier findSupplierWithDetails(Long supplierId) {
    // まずサプライヤーと商品を取得
    Supplier supplier = queryFactory
            .selectFrom(qSupplier)
            .leftJoin(qSupplier.products, qProduct).fetchJoin()
            .where(qSupplier.id.eq(supplierId))
            .fetchOne();
    
    // 必要なら別クエリで注文を取得
    // ...
    
    return supplier;
}

アンチパターン4: Qクラスのインスタンス共有問題

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 注意: 自己結合の場合は別インスタンスが必要
public List<Product> findCheaperProducts(Long productId) {
    QProduct subProduct = new QProduct("subProduct"); // 別名を指定
    
    return queryFactory
            .selectFrom(qProduct)
            .where(qProduct.price.lt(
                    JPAExpressions
                            .select(subProduct.price)
                            .from(subProduct)
                            .where(subProduct.id.eq(productId))
            ))
            .fetch();
}

まとめと実践Tips

Spring Data JPA Querydslは、タイプセーフなクエリを実現するための強力なツールです。

導入のポイント

項目 推奨事項
ビルド設定 Jakarta EE対応のclassifierを必ず指定
Qクラス生成 コンパイル時に自動生成されることを確認
IDE設定 生成ディレクトリをソースパスに追加

使い分けの指針

ユースケース 推奨アプローチ
単純なPredicate検索 QuerydslPredicateExecutor
動的な複合条件 BooleanBuilder
複雑なJOIN/サブクエリ JPAQueryFactory
特定フィールドのみ取得 Projection
一括更新/削除 update()/delete()

パフォーマンス最適化のチェックリスト

  • Fetch Joinで関連エンティティを一括取得する
  • Projectionで必要なカラムのみ取得する
  • インデックスが効く条件を優先的に記述する
  • ループ内でのクエリ実行を避ける
  • 大量データ処理ではストリーミングを検討する
  • 複数コレクションのFetch Joinは避ける

Querydslを適切に活用することで、保守性が高く、パフォーマンスに優れたデータアクセス層を構築できます。特にエンティティの構造変更時にコンパイルエラーとして問題を検出できる点は、大規模プロジェクトでの品質維持に大きく貢献します。

参考リンク