I recently had to try to make an endpoint with Spring Boot that would allow for very flexible querying. This would mean including filtering, sorting, and paging. Most REST endpoints are set up to deal with a single entity so I struggled to find an example/tutorial that included all three. Hopefully, you can benefit from the research that I have done. What I ended up with was a modified version of this tutorial
If you came here for the code then you can just skip down below, or you can jump to the code in Github
Overview
There’s a number of things that have to come together for all of this to work. I’ve broken this down into 5 different sections. I do make some assumptions; however, I will not be going into depth about Spring or JPA. I also assume your data source is SQL based. I know that this example will work for MySQL and SQL Server. However, your mileage may vary as you try different data sources.
Formatting the URL
Normally when making REST endpoints, this is the easiest step. However, due to the complexity of the queries, we have to get a bit creative. Eventually, I ended up with this format:1
/?search=columnName:value
In this example, I expect the endpoint to search my table for where the given column name is equal to the given value.
For my first iteration I included the following operations:
• :
(Equality, =)
• >
(Greater than)
• <
(Less than)
• !
(Negation, not equal)
• -
(Sort descending)
• +
(Sort ascending)
This would mean that a more complex query might look something like:1
/?search=col1:val1,col2<val2,col3>val3,col4!val4,col5-,col6+
This method worked for me but if you need more functionality you will want to figure that out at this stage as it will define what choices you make with the rest of the service.
Note!: When you’re formatting your URL, match the Java class attributes rather than the table column names! This means that if your column name is first_name but your entity object uses firstName, use firstName in the URL!
Parsing
Next, now that we have a URL defined, we need a way to parse out the search string.
First, we need to define our operations, pretty straight forward enum here.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public enum SearchOperation {
EQUALITY, NEGATION, GREATER_THAN, LESS_THAN, SORT_ASC, SORT_DESC;
public static final String[] SIMPLE_OPERATION_SET = {":", "!", ">", "<", "+", "-"};
public static SearchOperation getSimpleOperation(String input) {
switch (input) {
case ":":
return EQUALITY;
case "!":
return NEGATION;
case ">":
return GREATER_THAN;
case "<":
return LESS_THAN;
case "+":
case " ": // + is encoded in query strings as a space
return SORT_ASC;
case "-":
return SORT_DESC;
default:
return null;
}
}
}
Next, we need a simple class to house each search criteria:1
2
3
4
5public class SearchCriteria {
private String key;
private SearchOperation operation;
private Object value;
}
Then we need to write our parser. Just create a new class. I called mine: SearchCriteriaParser
At the top of that class, I defined the regex that will do most of the parsing.1
2
3
4
5
6
7private static String wordRegex = "[a-zA-Z]\\w*";
private static String valueRegex = "\\w+";
private static String operatorRegex = "(:|<|>|!|\\+|-|\\s)";
private static String timestampRegex = "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0 -9]{2}:[0-9]{2}:[0-9]{2}-[0-9]{2}:[0-9]{2}";
private static String idRegex = "\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}";
private static String fullRegex = "(" + wordRegex + ")" + operatorRegex + "(" + timestampRegex + "|" + idRegex + "|" + valueRegex + ")?,";
private static final Pattern searchPattern = Pattern.compile(fullRegex);
This looks complex, but it could be simplified for your needs. My input could include things like UUIDs and time stamps which could potentially conflict with recognition of operations. In the end we are looking for a repeating pattern: (word)(operation)(word?). However, that ‘word’ might take different shapes.
Finally, we need to use the regex to parse the search string:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public List<SearchCriteria> parse(String searchString) {
List<SearchCriteria> searchCriterias = new ArrayList<>();
if (searchString != null) {
Matcher matcher = searchPattern.matcher(searchString + ",");
while (matcher.find()) {
SearchCriteria searchCriteria = new SearchCriteria();
searchCriteria.setKey(matcher.group(1));
searchCriteria.setOperation(SearchOperation.getSimpleOperation(matcher.group(2)));
searchCriteria.setValue(matcher.group(3));
if ((searchCriteria.getOperation() != SearchOperation.SORT_DESC && searchCriteria.getOperation() != SearchOperation.SORT_ASC) || searchCriteria.getValue() == null) {
searchCriterias.add(searchCriteria);
}
}
}
return searchCriterias;
}
If we consider our simplified pattern of (word)(operation)(word?) we should only match on strings that match that pattern. Meaning anything that doesn’t match is thrown out.
Note!: Also, you might notice the grouping. In the regex, each group is defined by (). This makes it easier for us when we parse it out. Group 1 will always be the column name, group 2 will always be the operation and group 3 is the optional value. (Sorting doesn’t have a value!)
Building Specifications (Filtering)
Next, we need a way to use this nice list of search criteria. To do this, we need to generate some Specification
objects which are part of javax.persistance
Because we are generating the Specification
s dynamically, it’s easier to implement Specification
s and override the toPredicate
function:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class SearchSpecification implements Specification<MainObj> {
private SearchCriteria criteria;
public Predicate toPredicate(Root<MainObj> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
switch (criteria.getOperation()) {
case EQUALITY:
return criteriaBuilder.equal(root.get(criteria.getKey()), criteria.getValue());
case NEGATION:
return criteriaBuilder.notEqual(root.get(criteria.getKey()), criteria.getValue());
case LESS_THAN:
return criteriaBuilder.lessThan(root.get(criteria.getKey()), criteria.getValue().toString());
case GREATER_THAN:
return criteriaBuilder.greaterThan(root.get(criteria.getKey()), criteria.getValue().toString());
default:
return null;
}
}
}
After creating a SearchSpecification
with a criterion, the Specification will be able do all the filtering.
What this does behind the scenes is that it actually builds out the where clause of the SQL.
You will also need to make sure that your Repository can use Specifications. Just add a extends JpaSpecificationExecutor<>
.
1 | public interface MainRepository extends JpaRepository<MainObj, UUID>, JpaSpecificationExecutor<MainObj> { |
Ok, so far, we have a way to generate a list of Specification
s but the methods on the Repository
class will only accept one at a time. So, we need a function to and
all the Specification
s together:1
2
3
4
5
6
7
8
9
10
11private <T, V extends Specification<T>> Optional<Specification<T>> andSpecification(List<V> criteria) {
Iterator<V> itr = criteria.iterator();
if (itr.hasNext()) {
Specification<T> spec = Specification.where(itr.next());
while (itr.hasNext()) {
spec = spec.and(itr.next());
}
return Optional.of(spec);
}
return Optional.empty();
}
Finally, we have a single Specification
. The logic for executing all of that will look like this:1
2
3List<SearchCriteria> searchCriteria = searchCriteriaParser.parse(searchString);
List<SearchSpecification> specList = searchCriteria.stream().map(criterion -> new SearchSpecification(criterion)).collect(Collectors.toList());
Specification<MainObj> specs = andSpecification(specList).orElseThrow(() -> new IllegalArgumentException("No criteria provided"));
Building Sorts (With Paging)
To use sorting and paging, we need to use a Pageable
. A pageable has three parts: a page size, a page index, and a sort direction.
But first we need to convert our list of search criteria into Sort
.1
2
3
4
5
6
7
8
9
10
11
12private List<Sort> generateSortList(List<SearchCriteria> criteria) {
return criteria.stream().map((criterion) -> {
switch (criterion.getOperation()) {
case SORT_ASC:
return Sort.by(Order.asc(criterion.getKey()));
case SORT_DESC:
return Sort.by(Order.desc(criterion.getKey()));
default:
return null;
}
}).filter((sort) -> sort != null).collect(Collectors.toList());
}
Then we need to combine all the Sort
s. For this, I wrote a simple function that and
s all the Sort
s together:1
2
3
4
5
6
7
8
9
10
11
12private <T, V extends Sort> Optional<Sort> andSort(List<V> criteria) {
Iterator<V> itr = criteria.iterator();
if (itr.hasNext()) {
Sort sort = (itr.next());
while (itr.hasNext()) {
sort = sort.and(itr.next());
}
return Optional.of(sort);
}
return Optional.empty();
}
In the end the sorting logic will look like this:1
2
3List<Sort> sortList = generateSortList(searchCriteria);
Sort sort = andSort(sortList).orElse(Sort.unsorted());
Pageable pageable = PageRequest.of(pageIndex, pageSize, sort);
Combining It All Together
Let’s look at the core service function:1
2
3
4
5
6
7
8
9
10public List<MainObj> search(String searchString, Integer pageSize, Integer pageIndex) {
List<SearchCriteria> searchCriteria = searchCriteriaParser.parse(searchString);
List<SearchSpecification> specList = searchCriteria.stream().map(criterion -> new SearchSpecification(criterion)).collect(Collectors.toList());
Specification<MainObj> specs = andSpecification(specList).orElseThrow(() -> new IllegalArgumentException("No criteria provided"));
List<Sort> sortList = generateSortList(searchCriteria);
Sort sort = andSort(sortList).orElse(Sort.unsorted());
Pageable pageable = PageRequest.of(pageIndex, pageSize, sort);
Page page = mainRepository.findAll(specs, pageable);
return page.getContent();
}
Let’s review the steps:
- Parse the search string into
SearchCriteria
- Create
Specification
s for eachSearchCriteria
And
all of theSpecification
s together- Create Sorts for each qualifying criterion
And
all the sorts together- Create
Pageable
- Pass the
Specification
s and thePageable
to theRepository
- Get the content of the page
Still with me? Need to look at the source?
While this service doesn’t have every feature possible, the setup makes it so that you could easily expand on this. Adding more operations would be one simple example. Overall, I’m happy with the way this endpoint turned out.