Creating Dynamic Queries using JPA Specification and CriteriaBuilder in Spring Boot


Introduction

When building REST APIs, we often need to filter data based on various conditions — like searching users by name, email, or registration date. Writing separate queries for every combination can quickly become a nightmare.

That’s where JPA Specifications and CriteriaBuilder come in! They allow us to build dynamic queries based on incoming parameters, without cluttering the codebase.

In this primer, you’ll learn how to:

• Use Spring Data JPA’s Specification interface.

• Dynamically create queries using CriteriaBuilder.

• Combine multiple filters cleanly.

• Build a search API that takes flexible filtering options.


Prerequisites

Before starting, ensure you have the following:

• Basic knowledge of Spring Boot and Spring Data JPA.

• A working Spring Boot project with JPA configured.

• A database set up (e.g., H2, MySQL, PostgreSQL).

Project Structure

We’ll use a simple User entity as an example:

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;
    private String role;
    private LocalDate registrationDate;

    // Getters and Setters
}

Step 1: Add Spring Data JPA Dependency

Add the Spring Data JPA dependency to your pom.xml:

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


This allows us to use JPA repositories and specifications in the project.

Step 2: Extend JpaSpecificationExecutor in Repository

Next, update your UserRepository to extend JpaSpecificationExecutor:

public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
}


This step ensures that your repository can support dynamic queries using JPA Specifications.

Step 3: Define the Specification

Create a utility class UserSpecifications that will build our dynamic queries.

public class UserSpecifications {

    public static Specification<User> hasName(String name) {
        return (root, query, cb) -> name == null ? null : cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
    }

    public static Specification<User> hasEmail(String email) {
        return (root, query, cb) -> email == null ? null : cb.like(cb.lower(root.get("email")), "%" + email.toLowerCase() + "%");
    }

    public static Specification<User> hasRole(String role) {
        return (root, query, cb) -> role == null ? null : cb.equal(root.get("role"), role);
    }

    public static Specification<User> registeredAfter(LocalDate date) {
        return (root, query, cb) -> date == null ? null : cb.greaterThanOrEqualTo(root.get("registrationDate"), date);
    }
}


In each method, we return a Specification<User>. If the parameter is null, it returns null, and the filter is ignored.

Step 4: Combine Specifications Dynamically

Now, let’s create a service method to combine the filters based on user input:

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public List<User> searchUsers(String name, String email, String role, LocalDate registeredAfter) {
        Specification<User> spec = Specification.where(UserSpecifications.hasName(name))
                .and(UserSpecifications.hasEmail(email))
                .and(UserSpecifications.hasRole(role))
                .and(UserSpecifications.registeredAfter(registeredAfter));

        return userRepository.findAll(spec);
    }
}


The Specification.where() method creates the base query, and .and() is used to combine filters.

Step 5: Expose it via REST Controller

To make our dynamic query accessible via API, we’ll create a REST endpoint:

@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/search")
    public List<User> searchUsers(
            @RequestParam(required = false) String name,
            @RequestParam(required = false) String email,
            @RequestParam(required = false) String role,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate registeredAfter) {

        return userService.searchUsers(name, email, role, registeredAfter);
    }
}

Now, you can make requests like:

GET /api/users/search?name=John&role=ADMIN

This API supports dynamic filtering, applying only the filters that have values.

Why Use Specification + CriteriaBuilder?

Benefits of Using Specification and CriteriaBuilder:

Cleaner Code: Avoid cluttering the repository with numerous query methods.

Reusable Filters: Easily reuse query components across multiple parts of the application.

Flexibility: Dynamic filtering based on any combination of parameters.

Type-Safe: Avoid raw SQL or JPQL, which can be error-prone.

Leave a Reply

Your email address will not be published. Required fields are marked *