DEV Community

Noe Lopez
Noe Lopez

Posted on

Spring Data JPA custom repositories

Introduction

Spring Data JPA offers the JpaRepository interface which provides CRUD/List/Paging/Sorting capabilities. Then, query methods can be defined by:

  1. Query derived directly from method name. For example
public List<Customer> findTop5ByStatusOrderByDateOfBirthAsc( 
                         Customer.Status status);
Enter fullscreen mode Exit fullscreen mode
  1. Defining manually a query. This can be done using the @Query annotation
@Query("""
        SELECT c FROM Customer c 
        WHERE (c.status = :status or :status is null) 
          and (c.name like :name or :name is null) """)
    public List<Customer> findCustomerByStatusAndName(
            @Param("status") Customer.Status status,
            @Param("name") String name);
Enter fullscreen mode Exit fullscreen mode

New Java Text Blocks feature improves readability and code looks really clean.

However, there are situations in which neither of the above options fit our needs. For instance, if the method name involves a lot of fields, the method name can become unreadable. Or if the query is built based on certain criteria, multiple method names must be used for every combination. On the other hand, the @Query annotation is not suitable for dinamic queries. We may end up with performance issues if it is needed to check for null for many fields for example.

In these cases, we need to write our custom implementation for repository methods.

Customizing Repositories

Spring allows to create repositories with custom functionality. The steps to achieve this are:

  1. The first thing to do is to define a fragment interface with the specific methods of the custom repo.
  2. Implement the interface by providing methods functionality.
  3. The JPA repository interface of the Entity must extend the custom interface.

Case study:
Let's assume we have two entities related as shown below

@Entity
public class Customer {

    @Id
    @SequenceGenerator(name = "customer_id_sequence", sequenceName 
                    = "customer_id_sequence", allocationSize = 1)
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator 
                    = "customer_id_sequence")
    private Long id;
    private String name;
    private String email;
    private LocalDate dateOfBirth;

    @OneToOne(mappedBy = "customer", optional = false ,fetch = 
              FetchType.LAZY, cascade = CascadeType.ALL)
    @PrimaryKeyJoinColumn()
    private CustomerDetails details;

    public CustomerDetails getDetails() {
        return details;
    }

    public void setDetails(CustomerDetails details) {
        this.details = details;
        details.setCustomer(this);
    }
...
}
Enter fullscreen mode Exit fullscreen mode

The customer class has been used in previous posts and it contains just a few members.

@Table(name = "CUSTOMER_DETAILS")
@Entity(name = "CustomerDetails")
public class CustomerDetails {
    @Id
    @Column(name = "customer_id")
    private Long id;
    @Column
    private boolean  vip;
    @Lob
    @Column
    private String info;
    @Column
    private LocalDate createdOn;
    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "customer_id" )
    @JsonIgnore
    private Customer customer;
...
}
Enter fullscreen mode Exit fullscreen mode

Customer details is part of a bidirectional one-to-one relationship with Customer. It adds a few more fields and separates from the parent entity. This model will scale better if Customer is subject to high write operations.

Now, let's write our customer repo so that we can have total control on the new method. Our interface will have one method to search Customers based on all fields including CustomerDetails fields.

public interface CustomizedCustomerRepository {
    public List<Customer> findByAllFields(Customer customer);
}
Enter fullscreen mode Exit fullscreen mode

Next step is to implement the interface. There are two things to take into account here:

  1. The name of the class implementing the interface must be the fragment interface name followed by Impl postfix (Postfix can be changed using the annotation @EnableJpaRepositories)

  2. The implementation is not bound to Spring JPA. This gives the ability to use directly EntityManager or Spring JdbcTemplate. Even delegating to 3rd party library.

In our case, the will use the JPA Criteria API to build a dinamic query and filter by name, status, vip and info fields. Criteria API permits to create queries in a programmatically way.

Code is shown below

public class CustomizedCustomerRepositoryImpl implements 
    CustomizedCustomerRepository{

    @PersistenceContext
    private EntityManager entityManager;

    public List<Customer> findByAllFields(Customer customer) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Customer> query = 
            cb.createQuery(Customer.class);
        Root<Customer> root = query.from(Customer.class);

        query.select(root).where(buildPredicates(customer,cb, 
                                                 root));

        return entityManager.createQuery(query).getResultList();
    }

    private Predicate[] buildPredicates(Customer customer, 
        CriteriaBuilder cb, Root<Customer> root) {
        List<Predicate> predicates = new ArrayList<>();
        var details = (Join<Object, Object>)root.fetch("details");

        if (Objects.nonNull(customer.getName()))
            predicates.add(cb.like(root.get("name"), 
                "%"+customer.getName()+"%"));
        if (Objects.nonNull(customer.getStatus()))
            predicates.add(cb.equal(root.get("status"), 
                customer.getStatus()));
        if (Objects.nonNull(customer.getDetails().getInfo()))
            predicates.add(cb.equal(details.get("info"), 
                customer.getDetails().getInfo()));
        if (Objects.nonNull(customer.getDetails().isVip()))
            predicates.add(cb.equal(details.get("vip"), 
                customer.getDetails().isVip()));

        return predicates.toArray(new Predicate[0]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Lets take a look at some of the lines is the above code. First the entity manager is injected as we will make use of the Criteria API. Then, the necessary objects to construct the query object are instanciated.

Line

Root<Customer> root = query.from(Customer.class); 
Enter fullscreen mode Exit fullscreen mode

creates a root of type Customer for the from clause.

Line

query.select(root).where(buildPredicates(customer,cb,root));
Enter fullscreen mode Exit fullscreen mode

sets the root for the select statement and the where clause. The where clause is generated in the buildPredicates which returns a array of type Predicate. This is the input param type of the where method (varAgrs is compatible with array).

Finally, line

return entityManager.createQuery(query).getResultList();
Enter fullscreen mode Exit fullscreen mode

executes the query and returns the results as a list. Note that it is needed neither casting nor mapping as JPA knows the types from the Entities.

A couple of comments about the buildPredicates method. It constructs a join fecth in line

var details = (Join<Object, Object>) root.fetch("details");
Enter fullscreen mode Exit fullscreen mode

The join fetch will add the CustomerDetails fields to the select. It also prevents from the N + 1 query problem by joining the two tables (even if the one to one association is lazy).

Then, each predicate is added to a list if the corresponding field is not empty. Eventually, the list is converted to an Array.

The last step is to extend the individual repository from the customised.

public interface CustomerRepo extends 
    JpaRepository<Customer, Long> , CustomizedCustomerRepository {
...
}
Enter fullscreen mode Exit fullscreen mode

All is done now. Lets call the method from the Controller layer.

Calling the method

We will invoke the method from the CustomerController. Any of the four fields can be passed into the method via GET request params. The code is displayed in the following snippet

public class CustomerController {
...
    @GetMapping
    public List<CustomerResponse> findCustomers(
        @RequestParam(name="name", required=false) String name,
        @RequestParam(name="status", required=false) 
            Customer.Status status,
        @RequestParam(name="info", required=false) String info,
        @RequestParam(name="vip", required=false) Boolean vip) {
        return customerRepo
                .findByAllFields(Customer.Builder
                        .newCustomer()
                        .name(name)
                        .status(status)
                        .withDetails(info, Boolean.valueOf(vip))
                        .build())
                .stream()
                .map(CustomerUtils::convertToCustomerResponse)
                .collect(Collectors.toList());
    }
...
Enter fullscreen mode Exit fullscreen mode

All parameters are optional. If they are not informed they will not be part of the where clause. The customer object is created via Builder pattern with fluent api. Search results are converted to a DTO object CustomerResponse of type record. This way the representation of the data sent back to the client can have its own structure and not tied to the entities model.

Time to run a couple of tests. First, a GET request without params

http://localhost:8080/api/v1/customers

The generated sql code selects all columns for both entities and joins on the shared primary key. There is no where clause appended.

select c1_0.id,c1_0.date_of_birth,d1_0.customer_id, 
       d1_0.created_on,d1_0.info,d1_0.vip,c1_0.email, 
       c1_0.name,c1_0.status 
from customer c1_0 
join customer_details d1_0 on c1_0.id=d1_0.customer_id
Enter fullscreen mode Exit fullscreen mode

The output returns all Customers and its Customer Details. The DTO object is just a record with two nested record components.

[
    {
        "id": 1,
        "status": "DEACTIVATED",
        "personInfo": {
            "name": "name 1 surname 1",
            "email": "organisation1@email.com",
            "dateOfBirth": "03/01/1982"
        },
        "detailsInfo": {
            "info": "Customer info details 1",
            "vip": false
        }
    },
 ...
    {
        "id": 9,
        "status": "ACTIVATED",
        "personInfo": {
            "name": "name 9 surname 9",
            "email": "organisation9@email.com",
            "dateOfBirth": "27/09/1998"
        },
        "detailsInfo": {
            "info": "Customer info details 9",
            "vip": false
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

The second URL will have three parameters

http://localhost:8080/api/v1/customers?vip=true&status=activated &name=%name%

SQL code contains the where clause with the expected filters for the input params. This is demostrated in the below log entries

select c1_0.id,c1_0.date_of_birth,d1_0.customer_id, 
       d1_0.created_on,d1_0.info,d1_0.vip,c1_0.email, 
       c1_0.name,c1_0.status 
from customer c1_0 
join customer_details d1_0 on c1_0.id=d1_0.customer_id 
where c1_0.name like ? escape '' and c1_0.status=? and d1_0.vip=?

binding parameter [1] as [VARCHAR] - [%name%]
binding parameter [2] as [INTEGER] - [1]
binding parameter [3] as [BOOLEAN] - [true]
Enter fullscreen mode Exit fullscreen mode

The output sent back from the Controller holds the only Customer matching the filters

[
    {
        "id": 6,
        "status": "ACTIVATED",
        "personInfo": {
            "name": "name 6 surname 6",
            "email": "organisation6@email.com",
            "dateOfBirth": "18/06/1992"
        },
        "detailsInfo": {
            "info": "Customer info details 6",
            "vip": true
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

Conclusion

This article explained how to create a custom individual repository in Spring Data. This comes in handy when we need to enrich a repository with custom functionality and the options offered by the JpaRepository are not enough.

The code used in the article can be found in the github repository linked here

That is all for this week. New content related to Spring and Java will be publish sortly. Hope you enjoyed the read.

Top comments (1)

Collapse
 
tonymoonwalker profile image
tonymoonwalker

You forgot to mention that in your implementation interfaces CustomerRepo and CustomizedCustomerRepository should be stored in the same directory