1. Overview

In this quick article, we'll extend the advanced search operations that we implemented in the previous article and include OR-based search criteria into our REST API Query Language.

2. Implementation Approach

Before, all the criteria in the search query parameter formed predicates grouped only by AND operator. Let's change that.

We should be able to implement this feature either as a simple, quick change to existing approach or a new one from scratch.

With the simple approach, we'll flag the criteria to indicate that it must be combined using the OR operator.

For example, here is the URL to test the API for “firstName OR lastName”:

http://localhost:8080/users?search=firstName:john,'lastName:doe

Note that we have flagged the criteria lastName with a single quote to differentiate it. We will capture this predicate for OR operator in our criteria value object – SpecSearchCriteria:

public SpecSearchCriteria(
  String orPredicate, String key, SearchOperation operation, Object value) {
    super();
    
    this.orPredicate 
      = orPredicate != null
      && orPredicate.equals(SearchOperation.OR_PREDICATE_FLAG);
    
    this.key = key;
    this.operation = operation;
    this.value = value;
}

3. UserSpecificationBuilder Improvement

Now, let's modify our specification builder, UserSpecificationBuilder, to consider the OR qualified criteria when constructing Specification:

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

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

4. UserController Improvement

Finally, let's set up a new REST endpoint in our controller to use this search functionality with OR operator. The improved parsing logic extracts the special flag that helps in identifying the criteria with OR operator:

@GetMapping("/users/espec")
@ResponseBody
public List<User> findAllByOrPredicate(@RequestParam String search) {
    Specification<User> spec = resolveSpecification(search);
    return dao.findAll(spec);
}

protected Specification<User> resolveSpecification(String searchParameters) {
    UserSpecificationsBuilder builder = new UserSpecificationsBuilder();
    String operationSetExper = Joiner.on("|")
      .join(SearchOperation.SIMPLE_OPERATION_SET);
    Pattern pattern = Pattern.compile(
      "(\\p{Punct}?)(\\w+?)("
      + operationSetExper 
      + ")(\\p{Punct}?)(\\w+?)(\\p{Punct}?),");
    Matcher matcher = pattern.matcher(searchParameters + ",");
    while (matcher.find()) {
        builder.with(matcher.group(1), matcher.group(2), matcher.group(3), 
        matcher.group(5), matcher.group(4), matcher.group(6));
    }
    
    return builder.build();
}

5. Live Test With OR Condition

In this live test example, with the new API endpoint, we'll search for users by the first name “john” OR last name “doe”. Note that parameter lastName has a single quote, which qualifies it as an “OR predicate”:

private String EURL_PREFIX
  = "http://localhost:8082/spring-rest-full/auth/users/espec?search=";

@Test
public void givenFirstOrLastName_whenGettingListOfUsers_thenCorrect() {
    Response response = givenAuth().get(EURL_PREFIX + "firstName:john,'lastName:doe");
    String result = response.body().asString();

    assertTrue(result.contains(userJohn.getEmail()));
    assertTrue(result.contains(userTom.getEmail()));
}

6. Persistence Test With OR Condition

Now, let's perform the same test we did above, at the persistence level for users with first name “john” OR last name “doe”:

@Test
public void givenFirstOrLastName_whenGettingListOfUsers_thenCorrect() {
    UserSpecificationsBuilder builder = new UserSpecificationsBuilder();

    SpecSearchCriteria spec 
      = new SpecSearchCriteria("firstName", SearchOperation.EQUALITY, "john");
    SpecSearchCriteria spec1 
      = new SpecSearchCriteria("'","lastName", SearchOperation.EQUALITY, "doe");

    List<User> results = repository
      .findAll(builder.with(spec).with(spec1).build());

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

7. Alternative Approach

In the alternate approach, we could provide the search query more like a complete WHERE clause of SQL query.

For example, here is the URL for a more complex search by firstName and age:

http://localhost:8080/users?search=( firstName:john OR firstName:tom ) AND age>22

Note that we have separated individual criteria, operators & grouping parenthesis with a space to form a valid infix expression.

Let us parse the infix expression with a CriteriaParser. Our CriteriaParser splits the given infix expression into tokens (criteria, parenthesis, AND & OR operators) and creates a postfix expression for the same:

public Deque<?> parse(String searchParam) {

    Deque<Object> output = new LinkedList<>();
    Deque<String> stack = new LinkedList<>();

    Arrays.stream(searchParam.split("\\s+")).forEach(token -> {
        if (ops.containsKey(token)) {
            while (!stack.isEmpty() && isHigerPrecedenceOperator(token, stack.peek())) {
                output.push(stack.pop().equalsIgnoreCase(SearchOperation.OR_OPERATOR)
                  ? SearchOperation.OR_OPERATOR : SearchOperation.AND_OPERATOR);
            }
            stack.push(token.equalsIgnoreCase(SearchOperation.OR_OPERATOR) 
              ? SearchOperation.OR_OPERATOR : SearchOperation.AND_OPERATOR);

        } else if (token.equals(SearchOperation.LEFT_PARANTHESIS)) {
            stack.push(SearchOperation.LEFT_PARANTHESIS);
        } else if (token.equals(SearchOperation.RIGHT_PARANTHESIS)) {
            while (!stack.peek().equals(SearchOperation.LEFT_PARANTHESIS)) { 
                output.push(stack.pop());
            }
            stack.pop();
        } else {
            Matcher matcher = SpecCriteraRegex.matcher(token);
            while (matcher.find()) {
                output.push(new SpecSearchCriteria(
                  matcher.group(1), 
                  matcher.group(2), 
                  matcher.group(3), 
                  matcher.group(4), 
                  matcher.group(5)));
            }
        }
    });

    while (!stack.isEmpty()) {
        output.push(stack.pop());
    }
  
    return output;
}

Let us add a new method in our specification builder, GenericSpecificationBuilder, to construct the search Specification from the postfix expression:

    public Specification<U> build(Deque<?> postFixedExprStack, 
        Function<SpecSearchCriteria, Specification<U>> converter) {

        Deque<Specification<U>> specStack = new LinkedList<>();

        while (!postFixedExprStack.isEmpty()) {
            Object mayBeOperand = postFixedExprStack.pollLast();

            if (!(mayBeOperand instanceof String)) {
                specStack.push(converter.apply((SpecSearchCriteria) mayBeOperand));
            } else {
                Specification<U> operand1 = specStack.pop();
                Specification<U> operand2 = specStack.pop();
                if (mayBeOperand.equals(SearchOperation.AND_OPERATOR)) {
                    specStack.push(Specification.where(operand1)
                      .and(operand2));
                }
                else if (mayBeOperand.equals(SearchOperation.OR_OPERATOR)) {
                    specStack.push(Specification.where(operand1)
                      .or(operand2));
                }
            }
        }
        return specStack.pop();

Finally, let us add another REST endpoint in our UserController to parse the complex expression with the new CriteriaParser:

@GetMapping("/users/spec/adv")
@ResponseBody
public List<User> findAllByAdvPredicate(@RequestParam String search) {
    Specification<User> spec = resolveSpecificationFromInfixExpr(search);
    return dao.findAll(spec);
}

protected Specification<User> resolveSpecificationFromInfixExpr(String searchParameters) {
    CriteriaParser parser = new CriteriaParser();
    GenericSpecificationsBuilder<User> specBuilder = new GenericSpecificationsBuilder<>();
    return specBuilder.build(parser.parse(searchParameters), UserSpecification::new);
}

8. Conclusion

In this tutorial, we've improved our REST query language with the capability to search with an OR operator.

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.