Get docs

rest-api-search-language-spring-data-specifications

REST Query Language with Spring Data JPA Specifications

1. Overview

In this tutorial – we will build a Search/Filter REST API using Spring Data JPA and Specifications.

We started looking at a query language in the first article of this series – with a JPA Criteria based solution.

So – why a query language? Because – for any complex-enough API – searching/filtering your resources by very simple fields is simply not enough. A query language is more flexible and allows you to filter down to exactly the resources you need.

2. User Entity

First – let’s start with a simple User entity for our Search API:

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String firstName;
    private String lastName;
    private String email;

    private int age;

    // standard getters and setters
}

3. Filter Using Specification

Now – let’s get straight into the most interesting part of the problem – querying with custom Spring Data JPA Specifications.

We’ll create a UserSpecification which implements the Specification interface and we’re going to pass in our own constraint to construct the actual query:

public class UserSpecification implements Specification<User> {

    private SearchCriteria criteria;

    @Override
    public Predicate toPredicate
      (Root<User> root, CriteriaQuery<?> query, CriteriaBuilder builder) {

        if (criteria.getOperation().equalsIgnoreCase(">")) {
            return builder.greaterThanOrEqualTo(
              root.<String> get(criteria.getKey()), criteria.getValue().toString());
        }
        else if (criteria.getOperation().equalsIgnoreCase("<")) {
            return builder.lessThanOrEqualTo(
              root.<String> get(criteria.getKey()), criteria.getValue().toString());
        }
        else if (criteria.getOperation().equalsIgnoreCase(":")) {
            if (root.get(criteria.getKey()).getJavaType() == String.class) {
                return builder.like(
                  root.<String>get(criteria.getKey()), "%" + criteria.getValue() + "%");
            } else {
                return builder.equal(root.get(criteria.getKey()), criteria.getValue());
            }
        }
        return null;
    }
}

As we can see – we create a Specification based on some simple constrains which we represent in the following “SearchCriteria” class:

public class SearchCriteria {
    private String key;
    private String operation;
    private Object value;
}

The SearchCriteria implementation holds a basic representation of a constraint – and it’s based on this constraint that we’re going to be constructing the query:

  • key: the field name – for example, firstName, age, … etc.

  • operation: the operation – for example, equality, less than, … etc.

  • value: the field value – for example, john, 25, … etc.

Of course, the implementation is simplistic and can be improved; it is however a solid base for the powerful and flexible operations we need.

4. The UserRepository

Next – let’s take a look at the UserRepository; we’re simply extending the JpaSpecificationExecutor to get the new Specification APIs:

public interface UserRepository
  extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {}

5. Test the Search Queries

Now – let’s test out the new search API.

First, let’s create a few users to have them ready when the tests run:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { PersistenceJPAConfig.class })
@Transactional
@TransactionConfiguration
public class JPASpecificationsTest {

    @Autowired
    private UserRepository repository;

    private User userJohn;
    private User userTom;

    @Before
    public void init() {
        userJohn = new User();
        userJohn.setFirstName("John");
        userJohn.setLastName("Doe");
        userJohn.setEmail("[email protected]");
        userJohn.setAge(22);
        repository.save(userJohn);

        userTom = new User();
        userTom.setFirstName("Tom");
        userTom.setLastName("Doe");
        userTom.setEmail("[email protected]");
        userTom.setAge(26);
        repository.save(userTom);
    }
}

Next, let’s see how to find users with given last name:

@Test
public void givenLast_whenGettingListOfUsers_thenCorrect() {
    UserSpecification spec =
      new UserSpecification(new SearchCriteria("lastName", ":", "doe"));

    List<User> results = repository.findAll(spec);

    assertThat(userJohn, isIn(results));
    assertThat(userTom, isIn(results));
}

Now, let’s see how to find a user with given both first and last name:

@Test
public void givenFirstAndLastName_whenGettingListOfUsers_thenCorrect() {
    UserSpecification spec1 =
      new UserSpecification(new SearchCriteria("firstName", ":", "john"));
    UserSpecification spec2 =
      new UserSpecification(new SearchCriteria("lastName", ":", "doe"));

    List<User> results = repository.findAll(Specification.where(spec1).and(spec2));

    assertThat(userJohn, isIn(results));
    assertThat(userTom, not(isIn(results)));
}

Note: We used “where” and “and” to combine Specifications.

Next, let’s see how to find a user with given both last name and minimum age:

@Test
public void givenLastAndAge_whenGettingListOfUsers_thenCorrect() {
    UserSpecification spec1 =
      new UserSpecification(new SearchCriteria("age", ">", "25"));
    UserSpecification spec2 =
      new UserSpecification(new SearchCriteria("lastName", ":", "doe"));

    List<User> results =
      repository.findAll(Specification.where(spec1).and(spec2));

    assertThat(userTom, isIn(results));
    assertThat(userJohn, not(isIn(results)));
}

Now, let’s see how to search for User that doesn’t actually exist:

@Test
public void givenWrongFirstAndLast_whenGettingListOfUsers_thenCorrect() {
    UserSpecification spec1 =
      new UserSpecification(new SearchCriteria("firstName", ":", "Adam"));
    UserSpecification spec2 =
      new UserSpecification(new SearchCriteria("lastName", ":", "Fox"));

    List<User> results =
      repository.findAll(Specification.where(spec1).and(spec2));

    assertThat(userJohn, not(isIn(results)));
    assertThat(userTom, not(isIn(results)));
}

Finally – let’s see how to find a User given only part of the first name:

@Test
public void givenPartialFirst_whenGettingListOfUsers_thenCorrect() {
    UserSpecification spec =
      new UserSpecification(new SearchCriteria("firstName", ":", "jo"));

    List<User> results = repository.findAll(spec);

    assertThat(userJohn, isIn(results));
    assertThat(userTom, not(isIn(results)));
}

6. Combine Specifications

Next – let’s take a look at combining our custom Specifications to use multiple constraints and filter according to multiple criteria.

We’re going to implement a builder – UserSpecificationsBuilder – to easily and fluently combine Specifications:

public class UserSpecificationsBuilder {

    private final List<SearchCriteria> params;

    public UserSpecificationsBuilder() {
        params = new ArrayList<SearchCriteria>();
    }

    public UserSpecificationsBuilder with(String key, String operation, Object value) {
        params.add(new SearchCriteria(key, operation, value));
        return this;
    }

    public Specification<User> build() {
        if (params.size() == 0) {
            return null;
        }

        List<Specification> specs = params.stream()
          .map(UserSpecification::new)
          .collect(Collectors.toList());

        Specification result = specs.get(0);

        for (int i = 1; i < params.size(); i++) {
            result = params.get(i)
              .isOrPredicate()
                ? Specification.where(result)
                  .or(specs.get(i))
                : Specification.where(result)
                  .and(specs.get(i));
        }
        return result;
    }
}

7. UserController

Finally – let’s use this new persistence search/filter functionality and set up the REST API – by creating a UserController with a simple search operation:

@Controller
public class UserController {

    @Autowired
    private UserRepository repo;

    @RequestMapping(method = RequestMethod.GET, value = "/users")
    @ResponseBody
    public List<User> search(@RequestParam(value = "search") String search) {
        UserSpecificationsBuilder builder = new UserSpecificationsBuilder();
        Pattern pattern = Pattern.compile("(\\w+?)(:|<|>)(\\w+?),");
        Matcher matcher = pattern.matcher(search + ",");
        while (matcher.find()) {
            builder.with(matcher.group(1), matcher.group(2), matcher.group(3));
        }

        Specification<User> spec = builder.build();
        return repo.findAll(spec);
    }
}

Note that to support other non-English systems, the Pattern object could be changed like:

Pattern pattern = Pattern.compile("(\\w+?)(:|<|>)(\\w+?),", Pattern.UNICODE_CHARACTER_CLASS);

Here is a test URL example to test out the API:

http://localhost:8080/users?search=lastName:doe,age>25

And the response:

[{
    "id":2,
    "firstName":"tom",
    "lastName":"doe",
    "email":"[email protected]",
    "age":26
}]

Since the searches are split by a “,” in our Pattern example, the search terms can’t contain this character. The pattern also doesn’t match whitespace.

If we want to search for values containing commas, then we can consider using a different separator such as “;”.

Another option would be to change the pattern to search for values between quotes, then strip these from the search term:

Pattern pattern = Pattern.compile("(\\w+?)(:|<|>)(\"([^\"]+)\")");

8. Conclusion

This tutorial covered a simple implementation that can be the base of a powerful REST query language. We’ve made good use of Spring Data Specifications to make sure we keep the API away from the domain and have the option to handle many other types of operations.

The full implementation of this article can be found in the GitHub project – this is a Maven-based project, so it should be easy to import and run as it is.

Exit mobile version