Logo
Published on

Implement your Search/Filter Criteria API with Spring Data JPA Specifications

Authors
  • Name
    Twitter

In the world of Java development, managing database interactions can be a complex and time-consuming task. But with Spring Data JPA, a powerful and versatile framework, you can streamline your database operations and focus on building your application’s core functionality.

What is Spring Data JPA?

Spring Data JPA is a part of the larger Spring Data project, which simplifies database access in Java applications. JPA, or Java Persistence API, is a standard for object-relational mapping (ORM) in Java, and Spring Data JPA builds on top of this to provide an even more streamlined and efficient way to work with relational databases.

Since JPA 2.0, we have a Criteria API that allows to construct complex database queries using Specifications. In this article, we’ll explore how JPA Specifications can be used to build a search and filter API.

What is Spring Data JPA Specifications?

Spring Data JPA Specifications is a feature provided by the Spring Data JPA framework, which is a part of the larger Spring Data project. Spring Data JPA makes it easier to work with JPA (Java Persistence API) in a Spring application by providing a set of abstractions and utilities for common data access tasks.

Specifications in the context of Spring Data JPA are a way to build complex, dynamic queries for your JPA repositories. They allow you to define query predicates (criteria) as Java objects, which can be combined to create flexible and type-safe queries.

Spring Data JPA Specifications are powerful because they enable you to build dynamic queries without writing custom SQL or JPQL queries. They are particularly useful when you need to create queries with varying criteria based on user input or other runtime conditions.

Advantages of Spring Data JPA Specifications:

  • Specifications allow you to build complex queries dynamically at runtime. This is especially useful when you have a set of search criteria that can change based on user input or other runtime conditions.
  • You can encapsulate query criteria as reusable Specification objects. This promotes code reusability and maintainability, as you can easily use the same criteria in multiple places within your application.
  • By avoiding string-based queries, you reduce the risk of SQL injection attacks, as Specifications use parameterized queries by default.
  • Spring Data JPA can optimize queries generated from Specifications to minimize database round-trips and improve performance.

Disadvantages of Spring Data JPA Specifications:

  • While Specifications are quite powerful, they may not cover all possible query scenarios. In some cases, you might still need to resort to JPQL or SQL for highly complex queries.
  • While Specifications are excellent for generating JPQL queries, they might not be suitable for cases where you need to execute native SQL queries.
  • Constructing complex joins between multiple entities using Specifications can be challenging and may lead to verbose code.

We have a brief information and introduction about Specifications, time to code and implement it to our Spring Boot application.

How to implement Specification to our Spring Boot application?

First of all, if you don’t have an Spring Boot application, create a Spring Boot application with Spring Data JPA and a relational database (PostgreSQL, MySQL etc.) as a dependency to your Maven or Gradle.

If you have a Spring Boot application, make sure you have JPA dependency.

Here is how you should have it at Maven:

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

Here is how you should have it at Gradle:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}

Don’t forget to configure your database connection in your Spring Boot application.

Let’s create an Entity in our Spring Boot application.

@Entity
@Table(name = "phones")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Phone {

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

    @Column(name = "phone_name", nullable = false)
    private String phoneName;

    @Column(name = "phone_brand", nullable = false)
    private String phoneBrand;
}

To keep things simple, we created a Phone entity with a id primary key, and a phoneName and phoneBrand as string type.

So our entity will only have a phone name and phone brand and we will search/filter based on them.

Let’s create our Repository for Phone entity.

@Repository
public interface PhoneRepository extends JpaRepository<Phone, Integer>,
JpaSpecificationExecutor<Phone>{
}

If you are familiar with Spring Data JPA, you already know about JpaRepository <S, T> but as you see we have a JpaSpecificationExecutor <S>.

Let’s take a look what JpaSpecificationExecutor <S> is:

JpaSpecificationExecutor <S> interface is a part of Spring Data JPA and is typically used in conjunction with your JPA repository interface. The _ <S>_ represents the entity type (e.g., a specific JPA entity class). By extending this interface, your repository gains the ability to execute queries based on specifications.

So let’s implement our specifications for Phone entity.

Create a class named YourEntityNameSpecification (this is general syntax, you can name it if you want it in different way) at your repository folder ( folder where you keep your repositories).

After creating it, let’s implement a specification for filtering phones based on name and brands:

public class PhoneSpecification {

    public static Specification<Phone> filterPhone(String phoneBrand, String phoneName) {
        return (root, query, criteriaBuilder) -> {
            Predicate brandPredicate =
criteriaBuilder.like(root.get("phoneBrand"),StringUtils.isBlank(phoneBrand)
? likePattern("") : phoneBrand);
            Predicate namePredicate =
criteriaBuilder.like(root.get("phoneName"), StringUtils.isBlank(phoneName)
? likePattern("") : phoneName);
            return criteriaBuilder.and(namePredicate, brandPredicate);
        };
    }

    private static String likePattern(String value) {
        return "%" + value + "%";
    }
}

So let’s take a look at our Specification function:

  • root: Represents our entity (at our example is Phone entity)
  • query: Represents query being built.
  • criteriaBuilder: It provides a set of methods for building criteria expressions.
  • Predicate: An object that represents a condition or a set of conditions used to filter the results of a query.
  • likePattern(): This is a private utility method that takes a value as input and returns a string with wildcard % characters added to both ends. This is used to create a pattern for case-insensitive LIKE comparisons in the predicates.

So, to understand it easier, let’s look at SQL equivelant of this Specification:

SELECT * FROM p.Phone
WHERE
p.phone_brand = 'phoneBrand'
AND
p.phone_name = 'phoneName'

So basically, PhoneSpecification, provides a method filterPhone that generates a Specification for filtering a list of Phone objects based on certain criteria. The generated Specification combines two predicates:

  • brandPredicate: It checks if the “phoneBrand” property of a Phone object is similar to the provided phoneBrand parameter, allowing for a partial match (using the % wildcard) if phoneBrand is not blank or matching any brand if it is blank.
  • namePredicate: It checks if the “phoneName” property of a Phone object is similar to the provided phoneName parameter, allowing for a partial match (using the % wildcard) if phoneName is not blank or matching any name if it is blank.

The two predicates are combined using a logical AND operation, ensuring that both criteria are met for a Phone object to be included in the filtered result.

In case when there are no name and brand given, this specification will return all phones as a list, when we give parameters (name or brand) it will filter for us and return us as a list.

Why are we using StringUtils.isBlank?

To keep things simple within application, we will return an empty string in case when phoneName and phoneBrand is not provided just to protect our application from NullPointerExceptions and for making query parameters optional while sending request.

StringUtils used in example is imported from org.apache.commons.lang3.StringUtils and it is a dependency within application.

Here is maven dependency of StringUtils:

<dependency>
   <groupId>org.apache.commons</groupId>
   <artifactId>commons-lang3</artifactId>
   <version>3.12.0</version>
  </dependency>

Here is gradle dependency of StringUtils:

dependencies {
    implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0'
}

Add it to your Spring Boot application to use to check if parameters are null or not.

Let’s put our Specification in our Repository:

@Repository
public interface PhoneRepository extends JpaRepository<Phone, Integer>, JpaSpecificationExecutor<Phone>{

    List<Phone> findAll(Specification<Phone> specification);
}

As our specification will return a list of entity, usually findAll() function is used as it supports Specifications as parameter.

Let’s create our business logic at service:

@Service
@RequiredArgsConstructor
public class PhoneService {

    private final PhoneRepository phoneRepository;

    public List<Phone> findAllPhones(String phoneName, String phoneBrand) {
        final Specification<Phone> specification =
PhoneSpecification.filterPhone(phoneBrand, phoneName);
        final List<Phone> phones = phoneRepository.findAll(specification);
        return phones;
    }
}

So in our service, we create a Specification <S> variable and calling our created function at PhoneSpecification.

After that we put it at findAll() function and return it as a list.

Let’s create our controller to handle requests:

@RestController
@RequestMapping("/api/phones")
@RequiredArgsConstructor
public class PhoneController {

    private final PhoneService phoneService;

    @GetMapping
    public ResponseEntity<List<Phone>> getPhones(
            @RequestParam(required = false) String phoneBrand,
            @RequestParam(required = false) String phoneName
    ) {
        return ResponseEntity.ok(phoneService.findAllPhones(phoneName, phoneBrand));
    }
}

We will use a GET request to get phones from database and give filters as a query parameter but we won’t make it required as it is optional for user to filter it.

Enough implementation, let’s test it!

In my database, i have a few records of Phone products with Apple and Xiaomi brands:

So when I send a GET request from Postman to my endpoint without giving any filters, here is what returns:

// Response without filters

[
    {
        "id": 1,
        "phoneName": "Redmi Note 0",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 2,
        "phoneName": "iPhone 1",
        "phoneBrand": "Apple"
    },
    {
        "id": 3,
        "phoneName": "iPhone 2",
        "phoneBrand": "Apple"
    },
    {
        "id": 4,
        "phoneName": "Redmi Note 3",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 5,
        "phoneName": "iPhone 4",
        "phoneBrand": "Apple"
    },
    {
        "id": 6,
        "phoneName": "Redmi Note 5",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 7,
        "phoneName": "Redmi Note 6",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 8,
        "phoneName": "Redmi Note 7",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 9,
        "phoneName": "Redmi Note 8",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 10,
        "phoneName": "Redmi Note 9",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 11,
        "phoneName": "iPhone 10",
        "phoneBrand": "Apple"
    },
    {
        "id": 12,
        "phoneName": "Redmi Note 11",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 13,
        "phoneName": "Redmi Note 12",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 14,
        "phoneName": "Redmi Note 13",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 15,
        "phoneName": "iPhone 14",
        "phoneBrand": "Apple"
    },
    {
        "id": 16,
        "phoneName": "iPhone 15",
        "phoneBrand": "Apple"
    },
    {
        "id": 17,
        "phoneName": "iPhone 16",
        "phoneBrand": "Apple"
    },
    {
        "id": 18,
        "phoneName": "Redmi Note 17",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 19,
        "phoneName": "iPhone 18",
        "phoneBrand": "Apple"
    },
    {
        "id": 20,
        "phoneName": "iPhone 19",
        "phoneBrand": "Apple"
    },
    {
        "id": 21,
        "phoneName": "Redmi Note 20",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 22,
        "phoneName": "Redmi Note 21",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 23,
        "phoneName": "iPhone 22",
        "phoneBrand": "Apple"
    },
    {
        "id": 24,
        "phoneName": "Redmi Note 23",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 25,
        "phoneName": "Redmi Note 24",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 26,
        "phoneName": "iPhone 25",
        "phoneBrand": "Apple"
    },
    {
        "id": 27,
        "phoneName": "Redmi Note 26",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 28,
        "phoneName": "Redmi Note 27",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 29,
        "phoneName": "iPhone 28",
        "phoneBrand": "Apple"
    },
    {
        "id": 30,
        "phoneName": "Redmi Note 29",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 31,
        "phoneName": "iPhone 30",
        "phoneBrand": "Apple"
    },
    {
        "id": 32,
        "phoneName": "iPhone 31",
        "phoneBrand": "Apple"
    },
    {
        "id": 33,
        "phoneName": "Redmi Note 32",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 34,
        "phoneName": "Redmi Note 33",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 35,
        "phoneName": "iPhone 34",
        "phoneBrand": "Apple"
    },
    {
        "id": 36,
        "phoneName": "iPhone 35",
        "phoneBrand": "Apple"
    },
    {
        "id": 37,
        "phoneName": "Redmi Note 36",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 38,
        "phoneName": "Redmi Note 37",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 39,
        "phoneName": "Redmi Note 38",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 40,
        "phoneName": "Redmi Note 39",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 41,
        "phoneName": "iPhone 40",
        "phoneBrand": "Apple"
    },
    {
        "id": 42,
        "phoneName": "iPhone 41",
        "phoneBrand": "Apple"
    },
    {
        "id": 43,
        "phoneName": "iPhone 42",
        "phoneBrand": "Apple"
    },
    {
        "id": 44,
        "phoneName": "iPhone 43",
        "phoneBrand": "Apple"
    },
    {
        "id": 45,
        "phoneName": "iPhone 44",
        "phoneBrand": "Apple"
    },
    {
        "id": 46,
        "phoneName": "Redmi Note 45",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 47,
        "phoneName": "iPhone 46",
        "phoneBrand": "Apple"
    },
    {
        "id": 48,
        "phoneName": "iPhone 47",
        "phoneBrand": "Apple"
    },
    {
        "id": 49,
        "phoneName": "iPhone 48",
        "phoneBrand": "Apple"
    },
    {
        "id": 50,
        "phoneName": "iPhone 49",
        "phoneBrand": "Apple"
    }
]

Let’s give a filter by brand, let’s filter all Xiaomi phones:

// Response with Xiaomi filter

[
    {
        "id": 1,
        "phoneName": "Redmi Note 0",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 4,
        "phoneName": "Redmi Note 3",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 6,
        "phoneName": "Redmi Note 5",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 7,
        "phoneName": "Redmi Note 6",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 8,
        "phoneName": "Redmi Note 7",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 9,
        "phoneName": "Redmi Note 8",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 10,
        "phoneName": "Redmi Note 9",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 12,
        "phoneName": "Redmi Note 11",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 13,
        "phoneName": "Redmi Note 12",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 14,
        "phoneName": "Redmi Note 13",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 18,
        "phoneName": "Redmi Note 17",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 21,
        "phoneName": "Redmi Note 20",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 22,
        "phoneName": "Redmi Note 21",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 24,
        "phoneName": "Redmi Note 23",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 25,
        "phoneName": "Redmi Note 24",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 27,
        "phoneName": "Redmi Note 26",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 28,
        "phoneName": "Redmi Note 27",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 30,
        "phoneName": "Redmi Note 29",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 33,
        "phoneName": "Redmi Note 32",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 34,
        "phoneName": "Redmi Note 33",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 37,
        "phoneName": "Redmi Note 36",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 38,
        "phoneName": "Redmi Note 37",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 39,
        "phoneName": "Redmi Note 38",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 40,
        "phoneName": "Redmi Note 39",
        "phoneBrand": "Xiaomi"
    },
    {
        "id": 46,
        "phoneName": "Redmi Note 45",
        "phoneBrand": "Xiaomi"
    }
]

So we have a list of devices, let’s filter it brand by Xiaomi and phoneName by Redmi Note 45:

// Response with brand filtered by Xiaomi and name filtered by Redmi Note 45

[
    {
        "id": 46,
        "phoneName": "Redmi Note 45",
        "phoneBrand": "Xiaomi"
    }
]

So as you can see, it’s only returning one phone with exact values filtered.

What happens if I try to filter brand by Apple and name by Redmi Note?

This filter won’t work as it won’t return any response because there is no phone called Apple Redmi Note.

Filter implementation is done, let’s add a search to our Specification and allow user to make searches.

Let’s implement search to our Specification:

 public static Specification<Phone> searchPhone(String search) {
        return (root, query, criteriaBuilder) -> {
          Predicate brandPredicate = criteriaBuilder.like(root.get("phoneBrand"), likePattern(search));
          Predicate namePredicate = criteriaBuilder.like(root.get("phoneName"), likePattern(search));
          return criteriaBuilder.or(namePredicate, brandPredicate);
        };
    }

SQL equivelant of this specification:

SELECT * FROM Phone p
WHERE
p.phone_name LIKE "search"
OR
p.phone_brand LIKE "search"

So basically, this is a simple search specification that will search for phones.

You can repeat the same steps for implementing it to service and handling request at controller.

Let’s test it.

Let’s search for Apple:

// Response for Apple brand search

[
    {
        "id": 2,
        "phoneName": "iPhone 1",
        "phoneBrand": "Apple"
    },
    {
        "id": 3,
        "phoneName": "iPhone 2",
        "phoneBrand": "Apple"
    },
    {
        "id": 5,
        "phoneName": "iPhone 4",
        "phoneBrand": "Apple"
    },
    {
        "id": 11,
        "phoneName": "iPhone 10",
        "phoneBrand": "Apple"
    },
    {
        "id": 15,
        "phoneName": "iPhone 14",
        "phoneBrand": "Apple"
    },
    {
        "id": 16,
        "phoneName": "iPhone 15",
        "phoneBrand": "Apple"
    },
    {
        "id": 17,
        "phoneName": "iPhone 16",
        "phoneBrand": "Apple"
    },
    {
        "id": 19,
        "phoneName": "iPhone 18",
        "phoneBrand": "Apple"
    },
    {
        "id": 20,
        "phoneName": "iPhone 19",
        "phoneBrand": "Apple"
    },
    {
        "id": 23,
        "phoneName": "iPhone 22",
        "phoneBrand": "Apple"
    },
    {
        "id": 26,
        "phoneName": "iPhone 25",
        "phoneBrand": "Apple"
    },
    {
        "id": 29,
        "phoneName": "iPhone 28",
        "phoneBrand": "Apple"
    },
    {
        "id": 31,
        "phoneName": "iPhone 30",
        "phoneBrand": "Apple"
    },
    {
        "id": 32,
        "phoneName": "iPhone 31",
        "phoneBrand": "Apple"
    },
    {
        "id": 35,
        "phoneName": "iPhone 34",
        "phoneBrand": "Apple"
    },
    {
        "id": 36,
        "phoneName": "iPhone 35",
        "phoneBrand": "Apple"
    },
    {
        "id": 41,
        "phoneName": "iPhone 40",
        "phoneBrand": "Apple"
    },
    {
        "id": 42,
        "phoneName": "iPhone 41",
        "phoneBrand": "Apple"
    },
    {
        "id": 43,
        "phoneName": "iPhone 42",
        "phoneBrand": "Apple"
    },
    {
        "id": 44,
        "phoneName": "iPhone 43",
        "phoneBrand": "Apple"
    },
    {
        "id": 45,
        "phoneName": "iPhone 44",
        "phoneBrand": "Apple"
    },
    {
        "id": 47,
        "phoneName": "iPhone 46",
        "phoneBrand": "Apple"
    },
    {
        "id": 48,
        "phoneName": "iPhone 47",
        "phoneBrand": "Apple"
    },
    {
        "id": 49,
        "phoneName": "iPhone 48",
        "phoneBrand": "Apple"
    },
    {
        "id": 50,
        "phoneName": "iPhone 49",
        "phoneBrand": "Apple"
    }
]

Let’ s search for iPhone 1.

// Response for iPhone 1 search

[
    {
        "id": 2,
        "phoneName": "iPhone 1",
        "phoneBrand": "Apple"
    },
    {
        "id": 11,
        "phoneName": "iPhone 10",
        "phoneBrand": "Apple"
    },
    {
        "id": 15,
        "phoneName": "iPhone 14",
        "phoneBrand": "Apple"
    },
    {
        "id": 16,
        "phoneName": "iPhone 15",
        "phoneBrand": "Apple"
    },
    {
        "id": 17,
        "phoneName": "iPhone 16",
        "phoneBrand": "Apple"
    },
    {
        "id": 19,
        "phoneName": "iPhone 18",
        "phoneBrand": "Apple"
    },
    {
        "id": 20,
        "phoneName": "iPhone 19",
        "phoneBrand": "Apple"
    }
]

As you can see, it will return phones matching our search.

In conclusion, the Java Persistence API (JPA) specification is a powerful tool that has revolutionized how Java developers work with databases. Its flexibility, ease of use, and standardization have made it a go-to choice for many Java applications. With a deep understanding of JPA and its inner workings, developers can harness its full potential to create efficient, scalable, and maintainable database-driven applications. As the world of software development continues to evolve, JPA remains a key player in the realm of database management, offering a reliable and robust solution for all your persistence needs. So, whether you’re a seasoned JPA veteran or just starting your journey, embracing this specification can lead to greater efficiency, reduced development time, and a solid foundation for building high-quality Java applications.

Thank you for reading!