Specifications to the Rescue
Learn how Spring Boot, the JPA repository, and specification adoption can make it easy to provide filter solutions for your API.
Join the DZone community and get the full member experience.
Join For FreeWhile working in a project recently, I utilized Spring Data JPA Specifications (org.springframework.data.jpa.domain.Specification
) to provide some easy filtering for our RESTful API. I thought I would talk about my approach and findings here, using a sample project I created in my GitLab repository.
The Real Use Case
For the actual project, there was a panel in our Angular application that displayed a set of images that can be dragged and dropped onto the canvas where images are being organized to formulate a page. The challenge is that each instance of the application will contain a large inventory of images for use. As a result, a filtering option was required.
The legacy application did not allow multiple/additive filters based upon the technologies that were employed. However, since we were using Spring Boot with JPA repositories to access the data, I was able to leverage Specifications to allow for additive filters to be utilized.
My Example
For my example, I created two Java-based domain objects to represent members at a fitness club. The Member.class
file contains information about a given member, and the Class.class
contains classes that are available for attendance as a part of the gym membership. Note: I really hesitated with using Class as a class name.
Member.java
@Data
@Entity
public class Member {
@Id
@GeneratedValue
private long id;
private String firstName;
private String lastName;
private String zipCode;
private String interests;
private boolean active;
@JoinTable(name = "Member_Class",
joinColumns = @JoinColumn(
name = "member_id",
referencedColumnName = "id"
),
inverseJoinColumns = @JoinColumn(
name = "class_id",
referencedColumnName = "id"
))
@ManyToMany
private Set<Class> classes;
public Member() { }
}
Class.java
@Data
@Entity
public class Class {
@Id
@GeneratedValue
private long id;
private String name;
public Class() { }
}
When making the GET RESTful API call to the /members
URI using the following cURL:
curl -X GET \
http://localhost:9000/members
The following payload is returned from the in-memory H2 database that I configured for my Spring Boot project:
[
{
"id": 1,
"firstName": "Jon",
"lastName": "Anderson",
"zipCode": "90215",
"interests": "I like to write music and play racket sports",
"active": true,
"classes": [
{
"id": 102,
"name": "Tennis"
},
{
"id": 105,
"name": "Swimming"
}
]
},
{
"id": 6,
"firstName": "Geddy",
"lastName": "Lee",
"zipCode": "90212",
"interests": "I enjoy playing racquetball too",
"active": true,
"classes": [
{
"id": 102,
"name": "Tennis"
}
]
},
{
"id": 7,
"firstName": "Alex",
"lastName": "Lifeson",
"zipCode": "90211",
"interests": "I like staying in shape, drinking games",
"active": true,
"classes": [
{
"id": 104,
"name": "FitCore 2000"
},
{
"id": 105,
"name": "Swimming"
}
]
},
{
"id": 8,
"firstName": "Neil",
"lastName": "Peart",
"zipCode": "10010",
"interests": "I enjoy cycling, writing and playing drums",
"active": false,
"classes": [
{
"id": 101,
"name": "Spin"
},
{
"id": 102,
"name": "Tennis"
},
{
"id": 105,
"name": "Swimming"
}
]
},
{
"id": 2,
"firstName": "Trevor",
"lastName": "Rabin",
"zipCode": "90215",
"interests": "I am a guitar-playing point guard, who likes tennis too",
"active": true,
"classes": [
{
"id": 103,
"name": "Basketball"
}
]
},
{
"id": 4,
"firstName": "Chris",
"lastName": "Squire",
"zipCode": "33756",
"interests": "",
"active": false,
"classes": [
{
"id": 101,
"name": "Spin"
},
{
"id": 102,
"name": "Tennis"
},
{
"id": 105,
"name": "Swimming"
}
]
},
{
"id": 3,
"firstName": "Rick",
"lastName": "Wakeman",
"zipCode": "02215",
"interests": "I enjoy a good practical joke",
"active": true,
"classes": [
{
"id": 103,
"name": "Basketball"
}
]
},
{
"id": 5,
"firstName": "Alan",
"lastName": "White",
"zipCode": "90210",
"interests": "I have no interests",
"active": false,
"classes": [
{
"id": 104,
"name": "FitCore 2000"
}
]
}
]
Base Configuration
In order to implement Specifications, I had to update the MemberRepository
interface to extend the JpaSpecificationExecutor
class.
MemberRepository.java
@Repository
public interface MemberRepository extends JpaRepository<Member, Long>, JpaSpecificationExecutor { }
With the MemberRepository
class set, I then created a BaseSpecification
(abstract) class to include a Specification called getFilter()
:
BaseSpecification.java
public abstract class BaseSpecification<T, U> {
public abstract Specification<T> getFilter(U request);
}
My thought is that all Specifications that utilize the BaseSpecification
will utilize a getFilter()
method.
Member Specification Development
With the groundwork in place, I was ready to create the MemberSpecification
class, which extends the BaseSpecification
class noted above. The first step was override the getFilter()
method:
MemberSpecification.java
@Override
public Specification<Member> getFilter(FilterRequest request) {
return (root, query, cb) -> {
query.distinct(true);
query.orderBy(cb.asc(root.get("lastName")));
return where(isActive(request.getActive())
.and(inZipCode(request.getZipFilter())))
.toPredicate(root, query, cb);
};
}
The getFilter()
method will return distinct results, ordered by lastName
. The resulting data set will be the result of running theisActive()
andinZipCode()
methods, which are displayed below:
MemberSpecification.java
private Specification<Member> isActive(Boolean isActive) {
return (root, query, cb) -> {
if (isActive != null) {
return cb.equal(root.get("active"), isActive);
} else {
return null;
}
};
}
private Specification<Member> inZipCode(String zipFilter) {
return (root, query, cb) -> {
if (zipFilter != null) {
return cb.like(root.get("zipCode"), cb.literal(zipFilter + "%"));
} else {
return null;
}
};
}
With the above logic in place, the Members list can be easily filtered to find active/inactive members and those who have a zipCode
, which begins with the provided zipFilter
. So, a zipFilter
of "123" will return all zip codes that begin with "123" — keep in mind, this is just a simple example to show multiple filters.
Adding searchString Parameter
Like my actual application, the product owner wanted the end-user to have the ability to provide a search term, which could also be used to filter the data. In my example, I decided that the search term would be called searchString
and would search not only the interests attribute on theMember.class
but any name in the Class.class
in use.
To find matching interests for the searchString
, I created the following hasString()
method:
MemberSpecification.java
public Specification<Member> hasString(String searchString) {
return (root, query, cb) -> {
if (searchString != null) {
return cb.like(cb.lower(root.get("interests")), cb.lower(cb.literal("%" + searchString + "%")));
} else {
return null;
}
};
}
In order to locate matching classes, I first updated the ClassRepository
to include a helper query:
ClassRepository.java
public interface ClassRepository extends JpaRepository<Class, Long>, JpaSpecificationExecutor {
List<Class> findAllByNameContainsIgnoreCase(String searchString);
}
Next, I added the hasClasses()
method to the MemberSpecification
class:
MemberSpecification.java
public Specification<Member> hasClasses(String searchString) {
return (root, query, cb) -> {
if (searchString != null) {
List<Class> classes = classRepository.findAllByNameContainsIgnoreCase(searchString);
if (!CollectionUtils.isEmpty(classes)) {
SetJoin<Member, Class> masterClassJoin = root.joinSet("classes", JoinType.LEFT);
List<Predicate> predicates = new ArrayList<>();
predicates.add(masterClassJoin.in(new HashSet<>(classes)));
Predicate[] p = predicates.toArray(new Predicate[predicates.size()]);
return cb.or(p);
}
}
return null;
};
}
Applying Specifications
With everything now in place, I simply needed to reference the Specifications in my service class:
MemberService.java
public List<Member> getMembers(FilterRequest filter, String searchString) {
return memberRepository.findAll(Specification.where(memberSpecification.hasString(searchString)
.or(memberSpecification.hasClasses(searchString)))
.and(memberSpecification.getFilter(filter)));
}
As a result, when all Member
objects are being requested, if there is a searchString
, it will be utilized to locate LIKE matches in the interests attribute or the name attribute on the Class
objects. Additionally, filters by active/inactive status and/or zipCode
will also be returned.
Specifications in Action
Using the same dataset, the following CURL:
curl -X GET \
'http://localhost:9000/members?active=true&zipFilter=902&searchString=tennis'
Returns the filtered data:
[
{
"id": 1,
"firstName": "Jon",
"lastName": "Anderson",
"zipCode": "90215",
"interests": "I like to write music and play racket sports",
"active": true,
"classes": [
{
"id": 102,
"name": "Tennis"
},
{
"id": 105,
"name": "Swimming"
}
]
},
{
"id": 6,
"firstName": "Geddy",
"lastName": "Lee",
"zipCode": "90212",
"interests": "I enjoy playing racquetball too",
"active": true,
"classes": [
{
"id": 102,
"name": "Tennis"
}
]
},
{
"id": 2,
"firstName": "Trevor",
"lastName": "Rabin",
"zipCode": "90215",
"interests": "I am a guitar-playing point guard, who likes tennis too",
"active": true,
"classes": [
{
"id": 103,
"name": "Basketball"
}
]
}
]
All three results are active members, with a zipCode
that begins with 902 and the searchTerm
tennis being matched in either the interests attribute or the classes.name list/set.
Source Code
To see the complete source code, simply launch the following URL:
https://gitlab.com/johnjvester/jpa-spec
Have a really great day!
Opinions expressed by DZone contributors are their own.
Comments