11

Getting Started with Spring Data Specifications

 3 years ago
source link: https://reflectoring.io/spring-data-specifications/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

If you are looking for a better way to manage your queries or want to generate dynamic and typesafe queries then you might find your solution in Spring Data JPA Specifications.

Code Example

This article is accompanied by a working code example on GitHub.

What Are Specifications?

Spring Data JPA Specifications is yet another tool at our disposal to perform database queries with Spring or Spring Boot.

Specifications are built on top of the Criteria API.

When building a Criteria query we are required to build and manage Root, CriteraQuery, and CriteriaBuilder objects by ourselves:

...
EntityManager entityManagr = getEntityManager();

CriteriaBuilder builder = entityManager.getCriteriaBuilder();

CriteriaQuery<Product> productQuery = builder.createQuery(Product.class);

Root<Person> personRoot = productQuery.from(Product.class);
...

Specifications build on top of the Criteria API to simplify the developer experience. We simply need to implement the Specification interface:

interface Specification<T>{
 
  Predicate toPredicate(Root<T> root, 
            CriteriaQuery<?> query, 
            CriteriaBuilder criteriaBuilder);

}

Using Specifications we can build atomic predicates, and combine those predicates to build complex dynamic queries.

Specifications are inspired by the Domain-Driven Design “Specification” pattern.

Why Do We Need Specifications?

One of the most common ways to perform queries in Spring Boot is by using Query Methods like these:

interface ProductRepository extends JpaRepository<Product, String>, 
                  JpaSpecificationExecutor<Product> {
  
  List<Product> findAllByNameLike(String name);
  
  List<Product> findAllByNameLikeAndPriceLessThanEqual(
                  String name, 
                  Double price
                  );
  
  List<Product> findAllByCategoryInAndPriceLessThanEqual(
                  List<Category> categories, 
                  Double price
                  );
  
  List<Product> findAllByCategoryInAndPriceBetween(
                  List<Category> categories,
                  Double bottom, 
                  Double top
                  );
  
  List<Product> findAllByNameLikeAndCategoryIn(
                  String name, 
                  List<Category> categories
                  );
  
  List<Product> findAllByNameLikeAndCategoryInAndPriceBetween(
                  String name, 
                  List<Category> categories,
                  Double bottom, 
                  Double top
                  );
}

The problem with query methods is that we can only specify a fixed number of criteria. Also, the number of query methods increases rapidly as the use cases increases.

At some point, there are many overlapping criteria across the query methods and if there is a change in any one of those, we’ll have to make changes in multiple query methods.

Also, the length of the query method might increase significantly when we have long field names and multiple criteria in our query. Plus, it might take a while for someone to understand such a lengthy query and its purpose:

List<Product> findAllByNameLikeAndCategoryInAndPriceBetweenAndManufacturingPlace_State(String name,
                                             List<Category> categories,
                                             Double bottom, Double top,
                                             STATE state);

With Specifications, we can tackle these issues by creating atomic predicates. And by giving those predicates a meaningful name we can clearly specify their intent. We’ll see how we can convert the above into a much more meaningful query in the section Writing Queries With Specifications section.

Specifications allow us to write queries programmatically. Because of this, we can build queries dynamically based on user input. We’ll see this in more detail in the section Dynamic Queries With Specifications.

Setting Things Up

First, we need to have the Spring Data Jpa dependency in our build.gradle file:

...
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
annotationProcessor 'org.hibernate:hibernate-jpamodelgen'
...

We have also added add the hibernate-jpamodelgen annotation processor dependency which will generate static metamodel classes of our entities.

The Generated Metamodel

The classes generated by the Hibernate JPA model generator will allow us to write queries in a strongly-typed manner.

For instance, let’s look at the JPA entity Distributor:

@Entity
public class Distributor {
  @Id
  private String id;

  private String name;

  @OneToOne
  private Address address;
  //Getter setter ignored for brevity 

}

The metamodel class of the Distributor entity would look like the following:

@Generated(value = "org.hibernate.jpamodelgen.JPAMetaModelEntityProcessor")
@StaticMetamodel(Distributor.class)
public abstract class Distributor_ {

  public static volatile SingularAttribute<Distributor, Address> address;
  public static volatile SingularAttribute<Distributor, String> name;
  public static volatile SingularAttribute<Distributor, String> id;
  public static final String ADDRESS = "address";
  public static final String NAME = "name";
  public static final String ID = "id";

}

We can now use Distributor_.name in our criteria queries instead of directly using string field names of our entities. A major benefit of this is that queries using the metamodel evolve with the entities and are much easier to refactor than string queries.

Writing Queries With Specifications

Let’s convert the findAllByNameLike() query mentioned above into a Specification:

List<Product> findAllByNameLike(String name);

An equivalent Specification of this query method is:

private Specification<Product> nameLike(String name){
  return new Specification<Product>() {
   @Override
   public Predicate toPredicate(Root<Product> root, 
                  CriteriaQuery<?> query, 
                  CriteriaBuilder criteriaBuilder) {
     return criteriaBuilder.like(root.get(Product_.NAME), "%"+name+"%");
   }
  };
}

With a Java 8 Lambda we can simplify the above to the following:

private Specification<Product> nameLike(String name){
  return (root, query, criteriaBuilder) 
      -> criteriaBuilder.like(root.get(Product_.NAME), "%"+name+"%");
}

We can also write it in-line at the spot in the code where we need it:

...
Specification<Product> nameLike = 
      (root, query, criteriaBuilder) -> 
         criteriaBuilder.like(root.get(Product_.NAME), "%"+name+"%");
...

But this defeats our purpose of reusability, so let’s avoid this unless our use case requires it.

To execute Specifications we need to extend the JpaSpecificationExecutor interface in our Spring Data JPA repository:

interface ProductRepository extends JpaRepository<Product, String>, 
                  JpaSpecificationExecutor<Product> {
}

The JpaSpecificationExecutor interface adds methods which will allow us to execute Specifications, for example, these:

List<T> findAll(Specification<T> spec);

Page<T> findAll(Specification<T> spec, Pageable pageable);

List<T> findAll(Specification<T> spec, Sort sort);

Finally, to execute our query we can simply call:

List<Product> products = productRepository.findAll(namelike("reflectoring"));

We can also take advantage of findAll() functions overloaded with Pageable and Sort in case we are expecting a large number of records in the result or want records in sorted order.

The Specification interface also has the public static helper methods and(), or(), and where() that allow us to combine multiple specifications. It also provides a not() method which allows us to negate a Specification.

Let’s look at an example:

public List<Product> getPremiumProducts(String name, 
                    List<Category> categories) {
  return productRepository.findAll(
      where(belongsToCategory(categories))
          .and(nameLike(name))
          .and(isPremium()));
}

private Specification<Product> belongsToCategory(List<Category> categories){
  return (root, query, criteriaBuilder)-> 
      criteriaBuilder.in(root.get(Product_.CATEGORY)).value(categories);
}

private Specification<Product> isPremium() {
  return (root, query, criteriaBuilder) ->
      criteriaBuilder.and(
          criteriaBuilder.equal(
              root.get(Product_.MANUFACTURING_PLACE)
                        .get(Address_.STATE),
              STATE.CALIFORNIA),
          criteriaBuilder.greaterThanOrEqualTo(
              root.get(Product_.PRICE), PREMIUM_PRICE));
}

Here, we have combined belongsToCategory(), nameLike() and isPremium() specifications into one using the where() and and() helper functions. This also reads really nice, don’t you think? Also, notice how isPremium() is giving more meaning to the query.

Currently, isPremium() is combining two predicates, but if we want, we can create separate specifications for each of those and combine again with and(). For now, we will keep it as is, because the predicates used in isPremium() are very specific to that query, and if in the future we need to use them in other queries too then we can always split them up without impacting the clients of isPremium() function.

Dynamic Queries With Specifications

Let’s say we want to create an API that allows our clients to fetch all the products and also filter them based on a number of properties such as categories, price, color, etc. Here, we don’t know beforehand what combination of properties the client is going to use to filter the products.

One way to handle this is to write query methods for all possible combinations but that would require writing a lot of query methods. And that number would increase combinatorically as we introduce new fields.

A better solution is to take predicates directly from clients and convert them to database queries using specifications. The client has to simply provide us the list of Filters, and our backend will take care of the rest. Let’s see how we can do this.

First, let’s create an input object to take filters from the clients:

public class Filter {
  private String field;
  private QueryOperator operator;
  private String value;
  private List<String> values;//Used in case of IN operator
}

We will expose this object to our clients via a REST API.

Second, we need to write a function that will convert a Filter to a Specification:

private Specification<Product> createSpecification(Filter input) {
  switch (input.getOperator()){
    
    case EQUALS:
       return (root, query, criteriaBuilder) -> 
          criteriaBuilder.equal(root.get(input.getField()),
           castToRequiredType(root.get(input.getField()).getJavaType(), 
                              input.getValue()));
    
    case NOT_EQUALS:
       return (root, query, criteriaBuilder) -> 
          criteriaBuilder.notEqual(root.get(input.getField()),
           castToRequiredType(root.get(input.getField()).getJavaType(), 
                              input.getValue()));
    
    case GREATER_THAN:
       return (root, query, criteriaBuilder) -> 
          criteriaBuilder.gt(root.get(input.getField()),
           (Number) castToRequiredType(
                  root.get(input.getField()).getJavaType(), 
                              input.getValue()));
    
    case LESS_THAN:
       return (root, query, criteriaBuilder) -> 
          criteriaBuilder.lt(root.get(input.getField()),
           (Number) castToRequiredType(
                  root.get(input.getField()).getJavaType(), 
                              input.getValue()));
    
    case LIKE:
      return (root, query, criteriaBuilder) -> 
          criteriaBuilder.like(root.get(input.getField()), 
                          "%"+input.getValue()+"%");
    
    case IN:
      return (root, query, criteriaBuilder) -> 
          criteriaBuilder.in(root.get(input.getField()))
          .value(castToRequiredType(
                  root.get(input.getField()).getJavaType(), 
                  input.getValues()));
    
    default:
      throw new RuntimeException("Operation not supported yet");
  }
}

Here we have supported several operations such as EQUALS, LESS_THAN, IN, etc. We can also add more based on our requirements.

Now, as we know, the Criteria API allows us to write typesafe queries. So, the values that we provide must be of the type compatible with the type of our field. Filter takes the value as String which means we will have to cast the values to a required type before passing it to CriteriaBuilder:

private Object castToRequiredType(Class fieldType, String value) {
  if(fieldType.isAssignableFrom(Double.class)) {
    return Double.valueOf(value);
  } else if(fieldType.isAssignableFrom(Integer.class)) {
    return Integer.valueOf(value);
  } else if(Enum.class.isAssignableFrom(fieldType)) {
    return Enum.valueOf(fieldType, value);
  }
  return null;
}

private Object castToRequiredType(Class fieldType, List<String> value) {
  List<Object> lists = new ArrayList<>();
  for (String s : value) {
    lists.add(castToRequiredType(fieldType, s));
  }
  return lists;
}

Finally, we add a function that will combine multiple Filters to a specification:

private Specification<Product> getSpecificationFromFilters(List<Filter> filter){
  Specification<Product> specification = 
            where(createSpecification(queryInput.remove(0)));
  for (Filter input : filter) {
    specification = specification.and(createSpecification(input));
  }
  return specification;
}

Now, let’s try to fetch all the products belonging to the MOBILE or TV APPLIANCE category and whose prices are below 1000 using our new shiny dynamic specifications query generator.

Filter categories = Filter.builder()
     .field("category")
     .operator(QueryOperator.IN)
     .values(List.of(Category.MOBILE.name(), 
             Category.TV_APPLIANCES.name()))
     .build();

Filter lowRange = Filter.builder()
    .field("price")
    .operator(QueryOperator.LESS_THAN)
    .value("1000")
    .build();

List<Filter> filters = new ArrayList<>();
filters.add(lowRange);
filters.add(categories);

productRepository.getQueryResult(filters);

The above code snippets should do for most filter cases but there is still a lot of room for improvement. Such as allowing queries based on nested entity properties (manufacturingPlace.state) or limiting the fields on which we want to allow filters. Consider this as an open-ended problem.

When Should I Use Specifications Over Query Methods?

One question that comes to mind is that if we can write any query with specifications then when do we prefer query methods? Or should we ever prefer them? I believe there are a couple of cases where query methods could come in handy.

Let’s say our entity has only a handful of fields, and it only needs to be queried in a certain way then why bother writing Specifications when we can simply write a query method?

And if future requirements come in for more queries for the given entity then we can always refactor it to use Specifications. Also, Specifications won’t be helpful in cases where we want to use database-specific features in a query, for example performing JSON queries with PostgresSQL.

Conclusion

Specifications provide us with a way to write reusable queries and also fluent APIs with which we can combine and build more sophisticated queries.

All in all, Spring JPA Specifications is a great tool whether we want to create reusable predicates or want to generate typesafe queries programmatically.

Thank you for reading! You can find the working code at GitHub.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK