Introduction

Searching and filtering data is something we all do every day. It's like picking your favorite stuff when shopping online or looking for articles about a specific topic.
But here's the catch – as we dive into the vast world of data, we often find ourselves in a web of complexity.

Problem Statement:

Crafting complex queries in a Java application can be like finding a needle in a haystack with a magnifying glass, functional but not the smoothest experience.

As we strive to filter data using method names or queries, the complexity multiplies exponentially with each additional field.

Imagine a scenario where we have a Product entity with just 3 fields like
name, category, and price. If we aim to handle all possible combinations, considering just 3 fields, the number of queries required skyrockets to 7.

Using JPA method names:

public interface ProductRepository extends JpaRepository<Product, Long> {
   List<Product> findByName(String name);
   List<Product> findByCategory(String category);
   List<Product> findByPrice(double price);
   List<Product> findByNameAndCategory(String name, String category);
   List<Product> findByNameAndPrice(String name, double price);
   List<Product> findByCategoryAndPrice(String category, double price);
   List<Product> findByNameAndCategoryAndPrice(String name, String category, double price);

The rule here is that for n fields, you need 2^n - 1 queries to cover all possibilities!

Solution

We need a change, a way to make our queries friendlier, readable, and more efficient.
In the world of Java programming, JPA Specifications help us do this in a smart and organized way. Think of it as the GPS for your database searches. They take the hassle out of crafting queries, making the process dynamic and type-safe.

In this journey, we'll explore how JPA Specifications makes filtering data become as easy as picking your favorite item from an online store! ✨

To guide us through this exploration, we'll navigate the following key axes:

  1. Understanding JPA Specifications
  2. Setting Up Your Development Environment
  3. Unleashing the Power of JPA Specifications
  4. Testing Specifications in Action

Understanding JPA Specifications

Now that we've identified the complexities in crafting queries, let's embark on a journey to unravel the magic of JPA Specifications.

In the context of JPA, a "specification" is a way of describing complex conditions for querying data from a database.  JPA Specifications provide a mechanism to dynamically build and compose queries based on varying criteria at runtime.

Demystifying Predicates:

A predicate represents a condition that must be satisfied for an entity to be included in the query results.

For example, a predicate might specify that the "name" attribute should contain a certain value.

Predicate name = criteriaBuilder.like(root.get("name"), "%" + value + "%");
Example of a predicate

Criteria as Your Trusty Guides:

Criteria in JPA Specifications serves as the guiding principles for our predicates. It represents the standards that we follow when defining predicates.

Criteria essentially represent the overall conditions that must be met for an entity to be considered a match.

The CriteriaBuilder and CriteriaQuery  are tools that help us implement these guiding principles, enabling the construction of queries in a flexible and type-safe manner.

Here's a simplified example in JPA using the Spring Data JPA Specifications:

public class MySpecifications {
  public static Specification<MyEntity> nameContains(String str) {
    return (root, query, criteria) -> {
      String likeStr = "%" + str + "%";
      Predicate firstNameContains = criteria.like(root.get("firstName"), likeStr);
      Predicate lastNameContains = criteria.like(root.get("lastName"), likeStr);
      return criteria.or(firstNameContains, lastNameContains);
    };
  }
}

Relationship Overview: JPA Specifications
  • JPA Specifications: The overarching concept that encapsulates the entire structure.
  • Criteria: Acts as the guiding principles for the predicates.
  • Predicates: Intelligent conditions that define the criteria that data must meet.
  • Database Query: The ultimate result, showcasing how the criteria and predicates come together to form a powerful and expressive database query.

How JPA Specifications Differ from JPQL:

Now, let's draw a comparison with the traditional approach of using Java Persistence Query Language (JPQL). While JPQL involves crafting queries as strings within the code, JPA Specifications take a more direct, type-safe route, compile-time checking, enhanced code readability, and seamless support for refactoring.

In the next section, we'll get hands-on and explore the practical steps of setting up your development environment to leverage the capabilities of JPA Specifications. Get ready to dive into a world where database queries become not just a task but an enjoyable part of your coding journey! 🚀

Setting Up Your Development Environment

If you're already working with a Java project configured for JPA, you're in luck — no additional setup needed!

For those venturing into a new project or new to JPA, fear not. Here's a quick and breezy guide:

1.  Project Configuration:

For new projects or those yet to embrace JPA's magic, add the following dependency to your build tool (e.g., Maven or Gradle):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Example Maven Dependency for Spring Data JPA

2.  Database Configuration:

Update your application.properties or application.yml file with your database details:

# Example Database Configuration for MySQL
spring.datasource.url=jdbc:mysql://localhost:3306/your_database
spring.datasource.username=your_username
spring.datasource.password=your_password
Database Configuration for MySQL

3.  Entity Class:

Craft your entity class (e.g., Product) with the essential JPA annotations:

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private String name;
    private String category;
    private double price;
    private boolean isAvailable;
}
Product Entity

4.  Repository Interface:

Create a repository interface that extends JpaRepository:

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    // Additional custom queries can be defined here
}
Product Repository

5.  Service Layer (Optional):

For business logic and repository interactions, consider a service layer:

@AllArgsConstructor
@Service
public class ProductService {
    private final ProductRepository repository;
    // Your business logic and interactions with the repository go here
}
Product Service

Unleashing the Power of JPA Specifications:
Crafting, Combining, and Executing

Creating Custom Specifications:

We will start the journey by creating a custom specification that fits our criteria.

public class ProductSpecification {
    public static Specification<Product> likeName(String name) {
        return (root, query, criteriaBuilder) ->
                criteriaBuilder.like(root.get("name"), "%"+ name +"%");
    }
    public static Specification<Product> likeCategory(String cat) {
        return (root, query, criteriaBuilder) ->
                criteriaBuilder.like(root.get("category"), "%"+ cat +"%");
    }
    public static Specification<Product> lessThanPrice(double price) {
        return (root, query, criteriaBuilder) ->
                criteriaBuilder.lessThan(root.get("price"), price);
    }
    // Add more methods for additional criteria
}
  • likeName(String name): Generates a JPA Specification to filter products where the name attribute equals the provided name.
  • likeCategory(String category): Generates a JPA Specification to filter products where the category attribute equals the provided category.
  • lessThanPrice(double price): Generates a JPA Specification to filter products where the price attribute is less than the provided price.

Let's dive a bit deeper into this.

If the category itself is an entity, we can unlock the power of JPA Specifications.
By creating a connection with the Category entity through a join, we open the door to more advanced and flexible queries.

Now, check out the improved likeCategory function:

// import jakarta.persistence.criteria.Join;
// import jakarta.persistence.criteria.JoinType;
public static Specification<Product> likeCategory(String cat) {
	return (root, query, criteriaBuilder) -> {
    	Join<Product, Category> prodCatJoin = root.join("category", JoinType.INNER);
        return criteriaBuilder.like(prodCatJoin.get("name"), "%"+ cat +"%");
	};
}

See how JPA Specifications make it a breeze to create joins and mold our queries to match the unique aspects of our data?

Next, let's put our specifications into action in the service.

@AllArgsConstructor
@Service
public class ProductService {
    private final ProductRepository repository;
    // Your business logic and interactions with the repository go here
    public List<Product> findProductsByNameLike(String name) {
        Specification<Product> spec = ProductSpecification.likeName(name);
        return repository.findAll(spec);
    }
}
Using a single specification in the service

Executing Queries with JPA Specifications:

To execute the specification all we need is to add
JpaSpecificationExecutor<Product> to the repository:

@Repository
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
    // Additional custom queries can be defined here
}

And call the findAll function with the specification as argument
(productRepository.findAll(spec);)

We can also introduce a ProductSearchCriteria class to encapsulate your search criteria:

@Data
@Builder
public class ProductSearchCriteria {
    private String name;
    private String categoryName;
    private double price;
    private boolean isAvailable;
}

Encapsulating our search criteria in the ProductSearchCriteria class not only organizes our code but also enhances the readability and the maintainability.

In our last example, we kept it simple by only filtering based on the name attribute.
But let's be real — in actual projects, we rarely filter by just one thing.

This leads us to the next section, where we will dive into combining multiple specifications.

Combining Specifications:

A simple way to combine multiple specifications is to use the
Specification.where() and and() methods to chain multiple specifications. Let's create a new method in our service called findProductsByCriteria:

    public List<Product> findProductsByCriteria(
            ProductSearchCriteria criteria
    ) {
        Specification<Product> spec = Specification.where(null);
        String name = criteria.getName();
        if (name != null) {
            spec = spec.and(ProductSpecification.likeName(name));
        }
        String categoryName = criteria.getCategoryName();
        if (categoryName != null) {
            spec = spec.and(ProductSpecification.likeCategory(categoryName));
        }
        Double price = criteria.getPrice();
        if (price > 0) {
            spec = spec.and(ProductSpecification.lessThanPrice(price));
        }
        return repository.findAll(spec);
    }
Combining multiple specifications in the service
  • We begin by setting up our specification with Specification.where(null). It's like preparing a blank canvas to build our search criteria.
  • For each search field, we do a quick check to see if it's not empty. This ensures we only include relevant criteria in our search.
  • When we find a non-empty field, we work our magic by calling the correct spec method. For example, ProductSpecification.likeName(name) crafts a specification suitable for matching product names.
  • We smoothly combine these specifications using the spec.and(...) method.
  • With our specifications neatly combined, we're ready to roll. We pass our specifications as an argument to repository.findAll(spec). It's like telling the app, "Hey, fetch me the products that match these criteria."

Recognizing Limits:

While our current method of combining specifications in the service gets the job done, it's important to note that as we introduce more fields, this approach may lead to longer and less maintainable service methods.
This could potentially violate the principle of single responsibility.

An additional challenge lies in referencing entity attributes using strings, like
root.get("attributeName"), which introduces significant challenges. Firstly, developers must manually find and input attribute names, introducing the risk of human error. Also, if a column name changes later in the project, all queries relying on that name require refactoring, leading to a time-consuming and error-prone process.

Optimizing Specifications:

To enhance the scalability and maintainability of our code, we explore two robust solutions.

First, we centralize the specification logic in our dedicated
ProductSpecifications class, promoting cleaner and more modular code.

Simultaneously, we embrace the power of JPA Metamodel, enabling us to reference entity attributes in a type-safe manner. This approach not only eradicates the need for hardcoded strings but also introduces compile-time checks.

By combining these strategies, we achieve a comprehensive and scalable solution for crafting precise JPA Specifications.

1. Centralize Specification logic:

Let's dive into an illustrative example using the familiar Product entity:

public class ProductSpecification {
	public static Specification<Product> combinedSpec(ProductSearchCriteria criteria) {
        return Specification
                .where(likeName(criteria.getName()))
                .and(likeCategory(criteria.getCategoryName()))
                .and(lessThanPrice(criteria.getPrice()));
    }
    private static Specification<Product> likeName(String str) {
        return (root, query, criteriaBuilder) -> {
            if (!StringUtils.hasText(str)) return null;
            String formattedStr = "%" + str.toLowerCase() + "%";
            return criteriaBuilder.like(
                    criteriaBuilder.lower(root.get("name")),
                    formattedStr
            );
        };
    }
    private static Specification<Product> likeCategory(String str) {
        return (root, query, criteriaBuilder) -> {
            if (!StringUtils.hasText(str)) return null;
            String formattedCategoryStr = "%" + str.toLowerCase() + "%";
            Join<Category,Product> prodCatJoin = root.join("category", JoinType.INNER);
            return criteriaBuilder.like(
                    criteriaBuilder.lower(prodCatJoin.get("name")),
                    formattedCategoryStr
            );
        };
    }
    private static Specification<Product> lessThanPrice(Double value) {
        return (root, query, criteriaBuilder) -> {
            if (Objects.isNull(value) || value <= 0) return null;
            return criteriaBuilder.lessThan(root.get("price"), value);
        };
    }
}
  • Specification Chaining: The combinedSpec method chains various specifications (name, category, and price), combining them using the "and" logical operator to form a cohesive filter, ensuring that all criteria are met.
  • Individual Specifications: Specific methods like likeName, likeCategory, and lessThanPrice are responsible for generating specifications for each respective field. For instance, the likeName method creates a specification for the name field, applying a case-insensitive search.
  • Dynamic Predicate Composition: Predicates are dynamically crafted based on the presence and validity of search criteria, as determined by StringUtils.hasText(str). If a field, such as"name," has a valid value, a corresponding predicate is added to the filter. Otherwise returning null signifies that the specific criteria is skipped.
  • Final Specification: The resultant specification encapsulates the entire search criteria, offering a flexible and dynamic approach to filter products based on various attributes.

NB: We can extend this pattern by adding more methods for additional criteria.

Now, let’s use this combined specifications in our service:

@Service
public class ProductService {
	private final ProductRepository repository;
    // ... other service methods ...
    public List<Product> findProductsByCombinedSpec(
    ProductSearchCriteria criteria) {
        Specification<Product> spec = ProductSpecification.combinedSpec(criteria);
        return repository.findAll(spec);
    }
}

This approach keeps our service methods clean and modular, allowing us to easily add or remove individual criteria based on the search requirements.

2. Leveraging JPA Metamodel for String-Free Specifications

Let's advance our enhancement journey by embracing JPA Metamodel, a powerful tool that liberates us from the pitfalls associated with using plain strings in specifications.

For a rapid integration of JPA Metamodel , we'll briefly cover the setup and usage.

For more details, feel free to check this blog article:  "Criteria Queries Using JPA Metamodel".

Setup JPA Metamodel

First, you need to include JPA Metamodel dependency:

<!-- JPA Metamodel -->
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-jpamodelgen</artifactId>
    <version>6.2.6.Final</version>
</dependency>

Next, according to the Hibernate documentation, static metamodel generation should be integrated into a Maven project through the annotation processor paths of the Maven Compiler Plugin.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.11.0</version>
    <configuration>
        <source>${java.version}</source>
        <target>${java.version}</target>
        <annotationProcessorPaths>
            <path>
                <groupId>org.hibernate.orm</groupId>
                <artifactId>hibernate-jpamodelgen</artifactId>
                <version>${hibernate-jpamodelgen.version}</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>
Set up the annotation processor - Maven

For Gradle or Android Studio developers:

dependencies {
    annotationProcessor "org.hibernate.orm:hibernate-jpamodelgen:${hibernateVersion}"
}
Set up the annotation processor - Gradle
For more information you can check Hibernate documentation.

Once the dependency is added we can do a quick mvn clean install, then after the build we can find the generated class in the "target/generated-sources/annotations/com/example/jpaspecifications/entities"

Generated Metamodel

JPA Metamodel usage

Now we can go to our ProductSpecification and instead of calling
root.get("name"), we can replace it with root.get(Product_.name).

Earlier we mention that JPA Metamodel offers us the luxury of compile-time checks. Let's see that in action by calling the name field of Product instead of
Category.name in likeCategory method:

Compile-time checks in action

So this lead us to our final version of ProductSpecification :

public class ProductSpecification {
	public static Specification<Product> combinedSpec(ProductSearchCriteria criteria) {
        return Specification
                .where(likeName(criteria.getName()))
                .and(likeCategory(criteria.getCategoryName()))
                .and(lessThanPrice(criteria.getPrice()));
    }
    private static Specification<Product> likeName(String str) {
        return (root, query, criteriaBuilder) -> {
            if (!StringUtils.hasText(str)) return null;
            String formattedStr = "%" + str.toLowerCase() + "%";
            return criteriaBuilder.like(
                    criteriaBuilder.lower(root.get(Product_.name)),
                    formattedStr
            );
        };
    }
    private static Specification<Product> likeCategory(String str) {
        return (root, query, criteriaBuilder) -> {
            if (!StringUtils.hasText(str)) return null;
            String formattedCategoryStr = "%" + str.toLowerCase() + "%";
            Join<Product, Category> prodCatJoin = root.join(Product_.category, JoinType.INNER);
            return criteriaBuilder.like(
                    criteriaBuilder.lower(prodCatJoin.get(Category_.name)),
                    formattedCategoryStr
            );
        };
    }
    private static Specification<Product> lessThanPrice(Double value) {
        return (root, query, criteriaBuilder) -> {
            if (Objects.isNull(value) || value <= 0) return null;
            return criteriaBuilder.lessThan(root.get(Product_.price), value);
        };
    }
    // Add more methods for additional criteria
}

Testing Specifications in Action: Final touch

Now that we've completed the coding, it's crucial to ensure the effectiveness of our Specification. Let's perform a quick test to validate its functionality.
This step is essential to guarantee that our filtering logic is working as intended before integrating it into the broader context of our application.

Let initiate our test class by creating some dummy products.
Check the init() function in my repo.

@Test
void find_Products_By_Combined_Specifications() {
	// GIVEN
	Product shirt = allProducts.stream().filter(
    	product -> Objects.equals(
        	product.getCategory().getName(), 
            "Casual Wear"
            ) && product.getPrice() < 20d)
		.findFirst().orElse(null);
	ProductSearchCriteria criteria = ProductSearchCriteria.builder()
    			.categoryName("casual")
                .price(20d)
                .build();
	// WHEN
	List<Product> filteredProducts = productService
    			.findProductsByCombinedSpec(criteria);
	// THEN
	assertEquals(1, filteredProducts.size(), 
    		"Expected one product to match the criteria");
	Product firstFilteredProduct = filteredProducts.stream()
    			.findFirst().orElse(null);
    assertEquals(shirt, firstFilteredProduct, 
        	"Expected the filtered product to match " + shirt);
}

If you use the same init function as me you can notce that there is only one product under the category: "Casual Wear" and the price is less than "20".

Did you spot the isAvailable field in our Product entity? We left it untouched on purpose – it's your turn now! Challenge yourself by adding this criterion and testing different combinations. The best way to learn is by doing, so don't miss out on this hands-on opportunity!

Visit my GitHub repository to access the complete source code for this blog post. Feel free to fork, clone, and explore the examples!🔥

Conclusion

By adopting JPA Specifications, you not only streamline your code but also make it more readable and maintainable. The modular structure allows for the easy addition or modification of search criteria, adapting to the evolving needs of your application.

As with any tool, JPA Specifications come with their own set of challenges. Overusing specifications or creating overly complex queries can lead to performance issues. Additionally, as your application scales, managing an extensive list of specifications might become a concern. Striking the right balance and thoughtful design are key to harnessing the full potential of JPA Specifications.

So, as you dive into your next coding adventure, remember the simplicity and power that JPA Specifications bring to your fingertips. Happy coding!