Advanced Sort and Filtering REST API Using Spring Data and MongoDB

In this post, we’ll learn how to implement dynamic sort/filter for a Spring Boot REST API using Spring Data and MongoDB.

· Prerequisites
· Overview
· Real-world examples
· Coding
∘ Project Setup
∘ Creating the Document entity class
∘ Using Spring Data MongoDB
∘ Service layer
∘ Controllers
· Testing
· Conclusion
· References


Prerequisites

This is the list of all the prerequisites:

  • Maven 3.+
  • Spring Boot 3+
  • Java 17 or later
  • MongoDB instance (v6 or later) installed
  • Postman / insomnia or any other API testing tool.

Overview

It is common to perform complex searches in our API in production mode. Too much duplicate code has to be written to perform simple queries on each document.

Spring Data offers the ability to perform simple or complex queries on MongoDB documents.

In this story, we’ll focus on dynamically building search and paging queries in MongoDB.

Real-world examples

Let’s say we have a Mongo Database where we want to filter and sort for employees based on their properties.

Here is an example of an employee document:

The frontend UI looks like this:

The goal is to implement generic filtering and sorting for all properties with specific conditions. A sample with last name property below:

Coding

Project Setup

We start by creating a simple Spring Boot project from start.spring.io, with the following dependencies: Spring Web, Spring Data MongoDB, and Lombok.

Creating the Document entity class

To get started, we need a model class. For this story, we have a Employeeclass in which the Department class is embedded.

@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(collection = "department")
public class Department {

@NotNull
private String code;

private String name;
}
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(collection = "employee")
public class Employee {

@Id
private String id;

private String firstName;

private String lastName;

private String email;

@NotNull
private Department department;
}

Using Spring Data MongoDB

We create a common ResourceRepositoryinterface that extends from MongoRepository and will include all custom methods.

@NoRepositoryBean
public interface ResourceRepository<T, I extends Serializable> extends MongoRepository<T, I> {

Page<T> findAll(Query query, Pageable pageable);

List<T> findAllByQuery(Query query);

List<T> findAllByQuerySort(Query query, Sort sort);
}

Spring Data MongoDB provides a base repository SimpleMongoRepository class for MongoDB which implements the MongoRepository. It has all common implementation methods like save, saveAll, findById, findAll, count, delete, deleteById, etc…

We added a ResourceRepositoryImpl class that extended SimpleMongoRepository and implemented the ResourceRepository class.

public class ResourceRepositoryImpl<T, I extends Serializable> extends SimpleMongoRepository<T, I> implements ResourceRepository<T, I> {

public static final String QUERY_MUST_NOT_BE_NULL = "Query must not be null!";
private final MongoOperations mongoOperations;
private final MongoEntityInformation<T, I> entityInformation;

public ResourceRepositoryImpl(MongoEntityInformation<T, I> entityInformation, MongoOperations mongoOperations) {
super(entityInformation, mongoOperations);

this.entityInformation = entityInformation;
this.mongoOperations = mongoOperations;
}

@Override
public Page<T> findAll(final Query query, final Pageable pageable) {
Assert.notNull(query, QUERY_MUST_NOT_BE_NULL);

long total = mongoOperations.count(query, entityInformation.getJavaType(), entityInformation.getCollectionName());
List<T> content = mongoOperations.find(query.with(pageable), entityInformation.getJavaType(), entityInformation.getCollectionName());

return new PageImpl<>(content, pageable, total);
}

@Override
public List<T> findAllByQuery(Query query) {
Assert.notNull(query, QUERY_MUST_NOT_BE_NULL);
return mongoOperations.find(query, entityInformation.getJavaType(), entityInformation.getCollectionName());
}

@Override
public List<T> findAllByQuerySort(Query query, Sort sort) {
Assert.notNull(query, QUERY_MUST_NOT_BE_NULL);
return mongoOperations.find(query.with(sort), entityInformation.getJavaType(), entityInformation.getCollectionName());
}
}

EmployeeRepository will be extended to ResourceRepository. This will allow it to inherit the implicit methods provided by Spring Mongo Repository and ResourceRepository.

@Repository
public interface EmployeeRepository extends ResourceRepository<Employee, String> {
}

And then we need to tell Spring to enable the custom repository with the @EnableMongoRepositories annotation.

@SpringBootApplication
@EnableMongoRepositories(repositoryBaseClass = ResourceRepositoryImpl.class)
public class SpringDataMongodbDynamicQueriesApplication{

public static void main(String[] args) {
SpringApplication.run(SpringDataMongodbDynamicQueriesApplication.class, args);
}
}

Service layer

The service layer contains all business logic. We have, therefore, created a generic class, GenericFilterCriteriaBuilder, which will build all the requests before sending them to Spring Data.

The Criteria and Query classes provide a way to query MongoDB with Spring Data by centralizing typed queries, which helps us avoid syntax errors.

public class GenericFilterCriteriaBuilder {

private final List<FilterCondition> filterAndConditions;
private final List<FilterCondition> filterOrConditions;

private static final Map<String, Function<FilterCondition, Criteria>>
FILTER_CRITERIA = new HashMap<>();

// @formatter:off
static {
// Create map of filter
FILTER_CRITERIA.put("EQUAL", condition -> Criteria.where(condition.field()).is(condition.value()));
FILTER_CRITERIA.put("NOT_EQUAL", condition -> Criteria.where(condition.field()).ne(condition.value()));
FILTER_CRITERIA.put("GREATER_THAN", condition -> Criteria.where(condition.field()).gt(condition.value()));
FILTER_CRITERIA.put("GREATER_THAN_OR_EQUAL_TO", condition -> Criteria.where(condition.field()).gte(condition.value()));
FILTER_CRITERIA.put("LESS_THAN", condition -> Criteria.where(condition.field()).lt(condition.value()));
FILTER_CRITERIA.put("LESS_THAN_OR_EQUAL_TO", condition -> Criteria.where(condition.field()).lte(condition.value()));
FILTER_CRITERIA.put("CONTAINS", condition -> Criteria.where(condition.field()).regex((String) condition.value()));
FILTER_CRITERIA.put("JOIN", condition -> Criteria.where(condition.field()).is(condition.value()));
}
// @formatter:on


public GenericFilterCriteriaBuilder(SearchFilters searchFilters) {
this.filterAndConditions = searchFilters.filterAndConditions();
this.filterOrConditions = searchFilters.filterOrConditions();
}

public Query buildQuery() {

List<Criteria> criteriaAndClause = new ArrayList<>();
List<Criteria> criteriaOrClause = new ArrayList<>();
var criteria = new Criteria();

// build criteria
filterAndConditions.forEach(condition -> criteriaAndClause.add(buildCriteria(condition)));
filterOrConditions.forEach(condition -> criteriaOrClause.add(buildCriteria(condition)));

if (!CollectionUtils.isEmpty(criteriaAndClause) && !CollectionUtils.isEmpty(criteriaOrClause)) {
return new Query(criteria.andOperator(criteriaAndClause.toArray(new Criteria[0])).orOperator(criteriaOrClause.toArray(new Criteria[0])));
}

if (!CollectionUtils.isEmpty(criteriaAndClause)) {
return new Query(criteria.andOperator(criteriaAndClause.toArray(new Criteria[0])));
}
if (!CollectionUtils.isEmpty(criteriaOrClause)) {
return new Query(criteria.orOperator(criteriaOrClause.toArray(new Criteria[0])));
}

return new Query();
}


/**
* Build the predicate according to the request
*
* @param condition The condition of the filter requested by the query
* @return {@link Criteria}
*/
private Criteria buildCriteria(FilterCondition condition) {
Function<FilterCondition, Criteria>
function = FILTER_CRITERIA.get(condition.operator().name());

if (Objects.isNull(function)) {
throw new IllegalArgumentException("Invalid function param type: ");
}

return function.apply(condition);
}

}

GenericFilterCriteriaBuilder expected a list of andFilters and orFilters conditions as parameters. FilterCondition is composed of a field, operator, and value.

public record FilterCondition(String field, FilterOperation operator, Object value) {
}

The last helper class is FilterBuilder. It is used to prepare the parameters (page, orders, filterAnd. filterOr) from Controllers.

@Component
public class FilterBuilder {

private static final int DEFAULT_SIZE_PAGE = 20;

private static final String FILTER_SEARCH_DELIMITER = ",";
private static final String FILTER_CONDITION_DELIMITER = ";";

/**
* Prepare filter condition. extract the different filters used in the controller via @RequestParam
*
* @param criteria search Criteria.
* @return a list of {@link FilterCondition}
*/
public List<FilterCondition> createFilterCondition(String criteria) {
List<FilterCondition> filters = new ArrayList<>();

try {

if (StringUtils.hasLength(criteria)) {


List<String> values = split(criteria, FILTER_SEARCH_DELIMITER);
if (!CollectionUtils.isEmpty(values)) {
values.forEach(searchFilter -> {

// eg. field|eq|xxxx (| is filterConditionDelimiter)
List<String> filter = split(searchFilter, FILTER_CONDITION_DELIMITER);
// check if operator match
var operator = FilterOperation.fromValue(filter.get(1));
if (Objects.nonNull(operator)) {
filters.add(new FilterCondition(filter.get(0), operator, filter.get(2)));
}
});
}
}

return filters;

} catch (Exception ex) {
throw new BadRequestException(MessageFormat.format("Cannot create condition filter . Error: {0}", ex.getMessage()));
}

}

private static List<String> split(String search, String delimiter) {
return Stream.of(search.split(delimiter)).toList();
}

/**
* Get specification filters.
*
* @param filterOr filters or conditions
* @param filterAnd filters and conditions
* @return SearchFilters
*/
public SearchFilters getFilters(String filterAnd, String filterOr) {
return new SearchFilters(createFilterCondition(filterAnd), createFilterCondition(filterOr));
}

/**
* Get request pageable. Page Request Builder. custom pageable
*
* @param size the number of items to collect
* @param page page number
* @param orders search order filter (eg: field:ASC)
* @return PageRequest
*/
public PageRequest getPageable(int size, int page, String orders) {

int pageSize = (size <= 0) ? DEFAULT_SIZE_PAGE : size;
int currentPage = (page <= 0) ? 1 : page;
try {
var sorting = getSort(orders);

return PageRequest.of((currentPage - 1), pageSize, sorting);

} catch (Exception ex) {
throw new BadRequestException(MessageFormat.format("Cannot create condition filter. Error: {0}", ex.getMessage()));
}
}

/**
* Get request pageable. Page Request Builder. custom pageable
*
* @param orders search order filter (eg: field:ASC)
* @return PageRequest
*/
public Sort getSort(String orders) {

List<Sort.Order> orderList = new ArrayList<>();

try {

if (!StringUtils.hasLength(orders)) {
return Sort.unsorted();
}

List<String> multipleOrder = split(orders, FILTER_SEARCH_DELIMITER);
if (!CollectionUtils.isEmpty(multipleOrder)) {
multipleOrder.forEach(order -> {

List<String> values = split(order, FILTER_CONDITION_DELIMITER);
if (values.size() != 2) {
throw new BadRequestException("Value for param 'order' is not valid");
}
String column = values.get(0);
String sortDirection = values.get(1);

if (sortDirection.equalsIgnoreCase("ASC")) {
orderList.add(new Sort.Order(Sort.Direction.ASC, column));
}

if (sortDirection.equalsIgnoreCase("DESC")) {
orderList.add(new Sort.Order(Sort.Direction.DESC, column));
}
});

return Sort.by(orderList);
}

return Sort.unsorted();

} catch (Exception ex) {
throw new BadRequestException(MessageFormat.format("Cannot create extract orders params. Error: {0}", ex.getMessage()));
}
}


}

Controllers

Here is the EmployeeController:

@RestController
@RequestMapping("/employee")
public class EmployeeController {

private final EmployeeService employeeService;

public EmployeeController(EmployeeService employeeService) {
this.employeeService = employeeService;
}

/**
* @param page page number
* @param size size count
* @param filterOr string filter or conditions
* @param filterAnd string filter and conditions
* @param orders string orders
* @return PageResponse<Employee>
*/
@GetMapping(value = "/page")
public ResponseEntity<PageResponse<Employee>> getSearchCriteriaPage(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "20") int size,
@RequestParam(value = "or", required = false) String filterOr,
@RequestParam(value = "and", required = false) String filterAnd,
@RequestParam(value = "orders", required = false) String orders) {

PageResponse<Employee> response = new PageResponse<>();

var request = new FilterSortRegister(page, size, filterOr, filterAnd, orders);

Page<Employee> pg = employeeService.getPage(request);
response.setPageStats(pg, pg.getContent());

return new ResponseEntity<>(response, HttpStatus.OK);
}

/**
* @param filterOr string filter or conditions
* @param filterAnd string filter and conditions
* @return list of Employee
*/
@GetMapping()
public ResponseEntity<List<Employee>> getAllSearchCriteria(
@RequestParam(value = "or", required = false) String filterOr,
@RequestParam(value = "and", required = false) String filterAnd,
@RequestParam(value = "orders", required = false) String orders) {

var request = new FilterSortRegister(0, 0, filterOr, filterAnd, orders);

List<Employee> employees = employeeService.getAllSort(request);

return new ResponseEntity<>(employees, HttpStatus.OK);
}
}

Testing

Now we are all done with our code. We can run our application and test it.

  • Get the order of all employees in descending order by first name
  • Get pagination of 5 employees in ascending order by first name
  • Get pagination of 5 employees where lastName contains ‘r’ or department.code equal ‘TS’ in descending order by email

Conclusion

Well done !!. This post covered a dynamic implementation of sort/filter for a Spring Boot REST API using Spring Data and MongoDB.

The complete source code is available on GitHub.

This story is an improved version of the original version that I published on https://dzone.com

Support me through GitHub Sponsors.

Thank you for Reading !! See you in the next story.

References

👉 Link to Medium blog

Related Posts