[Spring Boot] Exploring Spring Boot 3 with QueryDSL — Part 2

Jay Kim
6 min readOct 19, 2023

Introduction

Here is the first part to this article. This article will cover how to set up and utilize QueryDSL with Spring Boot 3 application.

Set up

Dependency

Let’s begin by adding dependencies to pom.xml. Spring Boot 3 uses Jakarta EE. Therefore, <classifier>jakarta</classifier> is added to all querydsl-* dependencies.

Also, querydsl.version is included in the Spring Boot Starter Parent. Therefore, we can directly use that for QueryDSL version.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>io.jay</groupId>
<artifactId>service</artifactId>
<version>0.0.1-SNAPSHOT</version>

<properties>
<java.version>17</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<scope>test</scope>
</dependency>

<!-- QueryDSL -->
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>${querydsl.version}</version>
<classifier>jakarta</classifier>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>${querydsl.version}</version>
<classifier>jakarta</classifier>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

</project>

At this point, compiling code will generate some classes starting with Q. These generated classes will be used later in the implementation.

Application Properties

Here are the application properties related to the datasource and h2 console. I prefer yaml so here is what application.yml looks like:

spring:
datasource:
url: jdbc:h2:mem:testdb
username: sa
password:

h2:
console:
enabled: true

jpa:
show-sql: true

Configuration

@Configuration
class QueryDslConfiguration {

@PersistenceContext
private EntityManager em;

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

Entity Setup

Here are some entities which are going to be used in this example.

Team

package io.jay.service.repository;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.NoArgsConstructor;

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

@Entity
@Data
@NoArgsConstructor
@Table(name = "teams")
public class Team {

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

private String name;

@OneToMany(mappedBy = "team", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
private List<Member> members;

@OneToMany(mappedBy = "team", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
private List<Milestone> milestones;

public Team(String name) {
this.name = name;
this.members = new ArrayList<>();
this.milestones = new ArrayList<>();
}

public void addMember(Member member) {
this.members.add(member);
member.setTeam(this);
}

public void addMilestone(Milestone milestone) {
this.milestones.add(milestone);
milestone.setTeam(this);
}
}

Member

package io.jay.service.repository;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Data
@NoArgsConstructor
@Table(name = "members")
public class Member {

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

private String name;
private LocalDateTime since;

@ManyToOne
@JoinColumn(name = "team_id")
@EqualsAndHashCode.Exclude
private Team team;

public Member(String name) {
this.name = name;
this.since = LocalDateTime.now();
}
}

Milestone

This entity will not be used in the demonstration. However, this is added just to make the Team entity with two child entities.

package io.jay.service.repository;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Data
@NoArgsConstructor
@Table(name = "milestones")
public class Milestone {

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

private String name;
private LocalDateTime celebratedAt;

@ManyToOne
@JoinColumn(name = "team_id")
@EqualsAndHashCode.Exclude
private Team team;

public Milestone(String name) {
this.name = name;
this.celebratedAt = LocalDateTime.now();
}
}

QueryDSL Usage

Projection

First of all, let’s see how to map query results to a class. There are various ways to do projection for query results in QueryDSL. Out of all options, I prefer using Constructor.

In order to map the results to a class, all it needs is the @QueryProjection on the constructor.

package io.jay.service.model;

import com.querydsl.core.annotations.QueryProjection;

public record MemberResponse(long id, String name) {

@QueryProjection
public MemberResponse {
}
}

If you want to map the query results to a class with @Entity annotation, then the constructor projection needs to be done though a static created method (more details on official document).

Let’s see how the query result can be mapped to MemberResponse. Note that adding @QueryProjection then compiling code will generate a new class called QMemberResponse.

    public List<MemberResponse> findMembersByTeamId(long teamId) {
QMember memberTable = new QMember("member");

return queryFactory
.select(
new QMemberResponse(
memberTable.id,
memberTable.name
)
)
.from(memberTable)
.where(
memberTable.team.id.eq(teamId)
)
.fetch();
}

Dynamic Where

Is anyone tired of making new findBySomeColumn() methods in JpaRepository? Again I love Spring Data JPA for providing abstractions and convenience to create custom queries in a simple manner. The problem is that the users want to filter by different conditions all the time.

Let’s say for example, user wants to fetch all members who belong to a specific team. In addition to that users want to search by the member name.

In this case, team id will be mandatory and search text will be optional. For this, we can form the where clause with two boolean expressions. The nice thing about this implementation is that QueryDSL will ignore any boolean expressions returning null. If search text is null or empty, then this query will only filter by team id.

    public List<MemberResponse> searchMembersByTeamId(long teamId, String searchText) {
QMember memberTable = new QMember("member");

return queryFactory
.select(
new QMemberResponse(
memberTable.id,
memberTable.name
)
)
.from(memberTable)
.where(
memberTable.team.id.eq(teamId),
nameLike(searchText)
)
.fetch();
}

private BooleanExpression nameLike(String searchText) {
if (!StringUtils.hasText(searchText)) {
return null;
}

QMember memberTable = new QMember("member");
return memberTable.name.containsIgnoreCase(searchText);
}

Pagination

Last is pagination. We can use the same Pageable from Spring Data. Note that PageableExecutionUtils.getPage() is used instead of new PageImpl(...). This is an optimization technique to reduce the number of count calls.

    public Page<MemberResponse> members(Pageable pageable) {
QMember memberTable = new QMember("member");

List<MemberResponse> members = queryFactory
.select(
new QMemberResponse(
memberTable.id,
memberTable.name
)
)
.from(memberTable)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

return PageableExecutionUtils.getPage(members, pageable, () -> countQuery().fetchOne());
}

private JPAQuery<Long> countQuery() {
QMember memberTable = new QMember("member");
return queryFactory
.select(memberTable.count())
.from(memberTable);
}

Test Code

Ideally, any data used in test code should be prepared in tests. However, this example only has read operations and to keep things simpler for demonstration purposes, I will create test data using CommandLineRunner.

MainApplication.java

package io.jay.service;

import com.querydsl.jpa.impl.JPAQueryFactory;
import io.jay.service.model.MemberResponse;
import io.jay.service.model.TeamResponse;
import io.jay.service.repository.DefaultTeamQueryRepository;
import io.jay.service.repository.Member;
import io.jay.service.repository.Team;
import io.jay.service.repository.TeamRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.List;


@SpringBootApplication
public class MainApplication {

public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}
}

@Configuration
class QueryDslConfiguration {

@PersistenceContext
private EntityManager em;

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


@Component
@RequiredArgsConstructor
class DataInitializer implements CommandLineRunner {

private final TeamRepository repository;

@Override
@Transactional
public void run(String... args) {
Team firstTeam = new Team("First Team");
firstTeam.addMember(new Member("Jay"));
firstTeam.addMember(new Member("Steve"));
firstTeam.addMember(new Member("Jun"));
firstTeam.addMember(new Member("Joel"));

Team secondTeam = new Team("Second Team");
secondTeam.addMember(new Member("Ats"));
secondTeam.addMember(new Member("Ken"));
secondTeam.addMember(new Member("Yu"));

repository.saveAll(List.of(firstTeam, secondTeam));
}
}

@Controller
@ResponseBody
@RequiredArgsConstructor
class TeamController {

private final DefaultTeamQueryRepository query;

@GetMapping("/v1/teams/{teamId}/members")
public List<MemberResponse> members(@PathVariable long teamId) {
return query.findMembersByTeamId(teamId);
}

@GetMapping("/v2/teams/{teamId}/members")
public List<MemberResponse> searchMembers(@PathVariable long teamId, @RequestParam(required = false) String searchText) {
return query.searchMembersByTeamId(teamId, searchText);
}

@GetMapping("/v3/members")
public Page<MemberResponse> paginatedMembers(Pageable pageable) {
return query.members(pageable);
}
}

DefaultTeamQueryRepository.java

package io.jay.service.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import io.jay.service.model.MemberResponse;
import io.jay.service.model.QMemberResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class DefaultTeamQueryRepository {

private final JPAQueryFactory queryFactory;

public List<MemberResponse> findMembersByTeamId(long teamId) {
QMember memberTable = new QMember("member");

return queryFactory
.select(
new QMemberResponse(
memberTable.id,
memberTable.name
)
)
.from(memberTable)
.where(
memberTable.team.id.eq(teamId)
)
.fetch();
}


public List<MemberResponse> searchMembersByTeamId(long teamId, String searchText) {
QMember memberTable = new QMember("member");

return queryFactory
.select(
new QMemberResponse(
memberTable.id,
memberTable.name
)
)
.from(memberTable)
.where(
memberTable.team.id.eq(teamId),
nameLike(searchText)
)
.fetch();
}

private BooleanExpression nameLike(String searchText) {
if (!StringUtils.hasText(searchText)) {
return null;
}

QMember memberTable = new QMember("member");
return memberTable.name.containsIgnoreCase(searchText);
}


public Page<MemberResponse> members(Pageable pageable) {
QMember memberTable = new QMember("member");

List<MemberResponse> members = queryFactory
.select(
new QMemberResponse(
memberTable.id,
memberTable.name
)
)
.from(memberTable)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

return PageableExecutionUtils.getPage(members, pageable, () -> countQuery().fetchOne());
}

private JPAQuery<Long> countQuery() {
QMember memberTable = new QMember("member");
return queryFactory
.select(memberTable.count())
.from(memberTable);
}
}

MainApplicationTests.java

package io.jay.service;

import io.jay.service.model.MemberResponse;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;

import java.util.List;

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MainApplicationTests {

@Autowired
WebTestClient wtc;

@Nested
class Members {
@Test
void returnsMembers() {
var response = wtc.get()
.uri("/v1/teams/1/members")
.exchange()
.expectStatus()
.isOk()
.expectBodyList(MemberResponse.class)
.returnResult()
.getResponseBody();


assertThat(response).hasSize(4);
}
}

@Nested
class SearchMembers {
@Test
void returnsMembers() {
var response = wtc.get()
.uri("/v2/teams/1/members")
.exchange()
.expectStatus()
.isOk()
.expectBodyList(MemberResponse.class)
.returnResult()
.getResponseBody();


assertThat(response).hasSize(4);
}

@Test
void returnsMembersWithMatchingName() {
var response = wtc.get()
.uri("/v2/teams/1/members?searchText=jay")
.exchange()
.expectStatus()
.isOk()
.expectBodyList(MemberResponse.class)
.returnResult()
.getResponseBody();


assertThat(response).hasSize(1);
assertThat(response.get(0).name()).isEqualTo("Jay");
}
}

@Nested
class PaginatedMembers {
@Test
void returnsMembersWithPagination() {
wtc.get()
.uri("/v3/members?page=0&size=2")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("$.totalPages").isEqualTo(4)
.jsonPath("$.totalElements").isEqualTo(7)
.jsonPath("$.content.length()").isEqualTo(2);
}
}
}

--

--