Перейти к основному содержимому

Язык запросов REST — операции расширенного поиска

· 7 мин. чтения

1. Обзор

В этой статье мы расширим язык запросов REST, который мы разработали в предыдущих частях серии , чтобы включить больше операций поиска .

Теперь мы поддерживаем следующие операции: Равенство, Отрицание, Больше, Меньше, Начинается с, Заканчивается на, Содержит и Нравится.

Обратите внимание, что мы рассмотрели три реализации — критерии JPA, спецификации Spring Data JPA и Query DSL; в этой статье мы продолжаем использовать Спецификации, потому что это чистый и гибкий способ представления наших операций.

2. Перечисление SearchOperation ``

Во-первых, давайте начнем с определения лучшего представления наших различных поддерживаемых операций поиска с помощью перечисления:

public enum SearchOperation {
EQUALITY, NEGATION, GREATER_THAN, LESS_THAN, LIKE, STARTS_WITH, ENDS_WITH, CONTAINS;

public static final String[] SIMPLE_OPERATION_SET = { ":", "!", ">", "<", "~" };

public static SearchOperation getSimpleOperation(char input) {
switch (input) {
case ':':
return EQUALITY;
case '!':
return NEGATION;
case '>':
return GREATER_THAN;
case '<':
return LESS_THAN;
case '~':
return LIKE;
default:
return null;
}
}
}

У нас есть два набора операций:

  1. Простой – может быть представлен одним символом:
  • Равенство: представлено двоеточием ( : )
  • Отрицание: представлено восклицательным знаком ( ! )
  • Больше чем: представлено ( > )
  • Меньше чем: представлено ( < )
  • Нравится: представлено тильдой ( ~ )
  1. Сложные – нужно представить более одного символа:
  • Начинается с: представлено ( =prefix* )
  • Заканчивается на: представлено ( =*суффикс )
  • Содержит: представлено ( =*substring* )

Нам также нужно изменить наш класс SearchCriteria , чтобы использовать новый SearchOperation :

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

3. Изменить спецификацию пользователя

Теперь давайте включим новые поддерживаемые операции в нашу реализацию UserSpecification :

public class UserSpecification implements Specification<User> {

private SearchCriteria criteria;

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

switch (criteria.getOperation()) {
case EQUALITY:
return builder.equal(root.get(criteria.getKey()), criteria.getValue());
case NEGATION:
return builder.notEqual(root.get(criteria.getKey()), criteria.getValue());
case GREATER_THAN:
return builder.greaterThan(root.<String> get(
criteria.getKey()), criteria.getValue().toString());
case LESS_THAN:
return builder.lessThan(root.<String> get(
criteria.getKey()), criteria.getValue().toString());
case LIKE:
return builder.like(root.<String> get(
criteria.getKey()), criteria.getValue().toString());
case STARTS_WITH:
return builder.like(root.<String> get(criteria.getKey()), criteria.getValue() + "%");
case ENDS_WITH:
return builder.like(root.<String> get(criteria.getKey()), "%" + criteria.getValue());
case CONTAINS:
return builder.like(root.<String> get(
criteria.getKey()), "%" + criteria.getValue() + "%");
default:
return null;
}
}
}

4. Тесты на устойчивость

Далее — давайте протестируем наши новые операции поиска — на уровне персистентности:

4.1. Проверка равенства

В следующем примере мы будем искать пользователя по его имени и фамилии :

@Test
public void givenFirstAndLastName_whenGettingListOfUsers_thenCorrect() {
UserSpecification spec = new UserSpecification(
new SearchCriteria("firstName", SearchOperation.EQUALITY, "john"));
UserSpecification spec1 = new UserSpecification(
new SearchCriteria("lastName", SearchOperation.EQUALITY, "doe"));
List<User> results = repository.findAll(Specification.where(spec).and(spec1));

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

4.2. Отрицание теста

Далее, давайте найдем пользователей, которые по имени не «john» :

@Test
public void givenFirstNameInverse_whenGettingListOfUsers_thenCorrect() {
UserSpecification spec = new UserSpecification(
new SearchCriteria("firstName", SearchOperation.NEGATION, "john"));
List<User> results = repository.findAll(Specification.where(spec));

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

4.3. Тест больше, чем

Далее — будем искать пользователей с возрастом больше «25» :

@Test
public void givenMinAge_whenGettingListOfUsers_thenCorrect() {
UserSpecification spec = new UserSpecification(
new SearchCriteria("age", SearchOperation.GREATER_THAN, "25"));
List<User> results = repository.findAll(Specification.where(spec));

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

4.4. Тест начинается с

Далее — пользователи, чье имя начинается с «jo» :

@Test
public void givenFirstNamePrefix_whenGettingListOfUsers_thenCorrect() {
UserSpecification spec = new UserSpecification(
new SearchCriteria("firstName", SearchOperation.STARTS_WITH, "jo"));
List<User> results = repository.findAll(spec);

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

4.5. Тест заканчивается с

Далее мы будем искать пользователей, чьи имена заканчиваются на «n» :

@Test
public void givenFirstNameSuffix_whenGettingListOfUsers_thenCorrect() {
UserSpecification spec = new UserSpecification(
new SearchCriteria("firstName", SearchOperation.ENDS_WITH, "n"));
List<User> results = repository.findAll(spec);

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

4.6. Тест содержит

Теперь мы будем искать пользователей, чье имя содержит «oh» :

@Test
public void givenFirstNameSubstring_whenGettingListOfUsers_thenCorrect() {
UserSpecification spec = new UserSpecification(
new SearchCriteria("firstName", SearchOperation.CONTAINS, "oh"));
List<User> results = repository.findAll(spec);

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

4.7. Диапазон испытаний

Наконец, мы будем искать пользователей в возрасте от «20» до «25» :

@Test
public void givenAgeRange_whenGettingListOfUsers_thenCorrect() {
UserSpecification spec = new UserSpecification(
new SearchCriteria("age", SearchOperation.GREATER_THAN, "20"));
UserSpecification spec1 = new UserSpecification(
new SearchCriteria("age", SearchOperation.LESS_THAN, "25"));
List<User> results = repository.findAll(Specification.where(spec).and(spec1));

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

5. UserSpecificationBuilder

Теперь, когда постоянство сделано и протестировано, давайте перенесем наше внимание на веб-уровень.

Мы будем опираться на реализацию UserSpecificationBuilder из предыдущей статьи, чтобы включить новые новые операции поиска :

public class UserSpecificationsBuilder {

private List<SearchCriteria> params;

public UserSpecificationsBuilder with(
String key, String operation, Object value, String prefix, String suffix) {

SearchOperation op = SearchOperation.getSimpleOperation(operation.charAt(0));
if (op != null) {
if (op == SearchOperation.EQUALITY) {
boolean startWithAsterisk = prefix.contains("*");
boolean endWithAsterisk = suffix.contains("*");

if (startWithAsterisk && endWithAsterisk) {
op = SearchOperation.CONTAINS;
} else if (startWithAsterisk) {
op = SearchOperation.ENDS_WITH;
} else if (endWithAsterisk) {
op = SearchOperation.STARTS_WITH;
}
}
params.add(new SearchCriteria(key, op, value));
}
return this;
}

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

Specification 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;
}
}

6. Пользовательский контроллер

Далее — нам нужно изменить наш UserController , чтобы правильно анализировать новые операции :

@RequestMapping(method = RequestMethod.GET, value = "/users")
@ResponseBody
public List<User> findAllBySpecification(@RequestParam(value = "search") String search) {
UserSpecificationsBuilder builder = new UserSpecificationsBuilder();
String operationSetExper = Joiner.on("|").join(SearchOperation.SIMPLE_OPERATION_SET);
Pattern pattern = Pattern.compile(
"(\\w+?)(" + operationSetExper + ")(\p{Punct}?)(\\w+?)(\p{Punct}?),");
Matcher matcher = pattern.matcher(search + ",");
while (matcher.find()) {
builder.with(
matcher.group(1),
matcher.group(2),
matcher.group(4),
matcher.group(3),
matcher.group(5));
}

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

Теперь мы можем использовать API и получать правильные результаты с любой комбинацией критериев. Например, вот как будет выглядеть сложная операция с использованием API с языком запросов:

http://localhost:8080/users?search=firstName:jo*,age<25

И ответ:

[{
"id":1,
"firstName":"john",
"lastName":"doe",
"email":"john@doe.com",
"age":24
}]

7 . Тесты для API поиска

Наконец, давайте убедимся, что наш API работает хорошо, написав набор тестов API.

Начнем с простой настройки теста и инициализации данных:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(
classes = { ConfigTest.class, PersistenceConfig.class },
loader = AnnotationConfigContextLoader.class)
@ActiveProfiles("test")
public class JPASpecificationLiveTest {

@Autowired
private UserRepository repository;

private User userJohn;
private User userTom;

private final String URL_PREFIX = "http://localhost:8080/users?search=";

@Before
public void init() {
userJohn = new User();
userJohn.setFirstName("John");
userJohn.setLastName("Doe");
userJohn.setEmail("john@doe.com");
userJohn.setAge(22);
repository.save(userJohn);

userTom = new User();
userTom.setFirstName("Tom");
userTom.setLastName("Doe");
userTom.setEmail("tom@doe.com");
userTom.setAge(26);
repository.save(userTom);
}

private RequestSpecification givenAuth() {
return RestAssured.given().auth()
.preemptive()
.basic("username", "password");
}
}

7.1. Проверка равенства

Во-первых, давайте найдем пользователя с именем « john » и фамилией « doe » :

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

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

7.2. Отрицание теста

Теперь мы будем искать пользователей, если их имя не «john» :

@Test
public void givenFirstNameInverse_whenGettingListOfUsers_thenCorrect() {
Response response = givenAuth().get(URL_PREFIX + "firstName!john");
String result = response.body().asString();

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

7.3. Тест больше, чем

Далее — будем искать пользователей с возрастом больше «25» :

@Test
public void givenMinAge_whenGettingListOfUsers_thenCorrect() {
Response response = givenAuth().get(URL_PREFIX + "age>25");
String result = response.body().asString();

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

7.4. Тест начинается с

Далее — пользователи, чье имя начинается с «jo» :

@Test
public void givenFirstNamePrefix_whenGettingListOfUsers_thenCorrect() {
Response response = givenAuth().get(URL_PREFIX + "firstName:jo*");
String result = response.body().asString();

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

7.5. Тест заканчивается с

Теперь — пользователи, чьи имена заканчиваются на «n» :

@Test
public void givenFirstNameSuffix_whenGettingListOfUsers_thenCorrect() {
Response response = givenAuth().get(URL_PREFIX + "firstName:*n");
String result = response.body().asString();

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

7.6. Тест содержит

Далее мы будем искать пользователей, чье имя содержит «oh» :

@Test
public void givenFirstNameSubstring_whenGettingListOfUsers_thenCorrect() {
Response response = givenAuth().get(URL_PREFIX + "firstName:*oh*");
String result = response.body().asString();

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

7.7. Диапазон испытаний

Наконец, мы будем искать пользователей в возрасте от «20» до «25» :

@Test
public void givenAgeRange_whenGettingListOfUsers_thenCorrect() {
Response response = givenAuth().get(URL_PREFIX + "age>20,age<25");
String result = response.body().asString();

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

8. Заключение

В этой статье мы довели язык запросов нашего REST Search API до зрелой, протестированной реализации производственного уровня . Теперь мы поддерживаем широкий спектр операций и ограничений, что должно упростить элегантное пересечение любого набора данных и получение именно тех ресурсов, которые мы ищем.

Полную реализацию этой статьи можно найти в проекте GitHub — это проект на основе Maven, поэтому его легко импортировать и запускать как есть.