Introduction
Spring Data JPA offers the JpaRepository interface which provides CRUD/List/Paging/Sorting capabilities. Then, query methods can be defined by:
- Query derived directly from method name. For example
public List<Customer> findTop5ByStatusOrderByDateOfBirthAsc(
Customer.Status status);
- 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);
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:
- The first thing to do is to define a fragment interface with the specific methods of the custom repo.
- Implement the interface by providing methods functionality.
- 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);
}
...
}
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;
...
}
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);
}
Next step is to implement the interface. There are two things to take into account here:
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)
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]);
}
}
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);
creates a root of type Customer for the from clause.
Line
query.select(root).where(buildPredicates(customer,cb,root));
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();
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");
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 {
...
}
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());
}
...
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
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
}
}
]
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]
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
}
}
]
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)
You forgot to mention that in your implementation interfaces CustomerRepo and CustomizedCustomerRepository should be stored in the same directory