Pull to refresh
1016.05
OTUS
Цифровые навыки от ведущих экспертов

Spring Data Specification: наложение фронтенд-фильтров на репозитории spring data

Reading time6 min
Views6.2K

Коллеги, добрый день. Во многих проектах для работы с данными на бекенде используется Spring Data со всеми плюшками использования декларативного подхода описания запросов к данным на основе интерфейсов. Также очень часто бекенду нужно быть очень универсальным, чтобы обрабатывать произвольные запросы от фронтенда.

Рассмотрим ситуацию: мы реализуем интернет-магазин. Со стороны UI пользователь имеет возможность осуществлять поиск товаров, задавания произвольное количество фильтров. Например, он может указать в качестве фильтров для поиска:

  • Найти все книги с заданным названием.

  • Найти все книги, у которых жанр - фантастика и имя автора начинается с “абв”.

  • Найти все книги с обложкой желтого или красного цвета.

Со стороны бекенда сущности Книга, Жанр, Автор и Тег хранятся в разных таблицах и соединяются по foreign key. Для каждой сущности есть свой отдельный репозиторий и Entity.

Когда бекенд получает от фронтенда запрос на поиск, то необходимо преобразовать этот произвольный набор фильтров и в соответствии с ними выбрать:

  • нужный репозиторий

  • нужный метод в этом репозитории

  • определить, нужно ли делать join, чтобы отфильтровать сущности по полю соединенной таблицы

Задача: на основе произвольных json-фильтров, приходящих с фронтенда формировать sql-запросы с условием по полям, указанным в фильтрах.

Более подробно про особенности интерфейса Specification описано в статье.

Если кратко, то Specification помогают наложить фронтенд-фильтры на репозитории spring data.

Объект фильтра, приходящего с фронтенда:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SearchCriteria implements Filter {
private final String type = "SEARCH";
/**
* Название поля по которому ищем (должно соответствовать названию поля в
таблице БД)
*/
@ApiModelProperty(value = "Название поля по которому ищем (должно
соответствовать названию поля в таблице БД)")
private String key;
/**
* Название таблицы, которую присоединяем (для вложенных полей, например,
пользователь author)
*/
@ApiModelProperty(value = "Название таблицы, которую присоединяем (для
вложенных полей, например, пользователь author)")
private String table;
/**
* Операция сравнения для фильтрации (EQ, GR, LO, IN)
*/
@ApiModelProperty(value = "Операция сравнения для фильтрации (EQ, GR, LO,
IN)")
private String operation;
/**
* Значение по которому ищем
*/
@ApiModelProperty(value = "Значение по которому ищем")
private Object value;
}

Поскольку поиск может осуществляться не по одному, а по нескольким полям сразу,

можно добавить фильтры для OR и AND, а также общий интерфейс для всех фильтров:

@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "type",
include = JsonTypeInfo.As.EXISTING_PROPERTY
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OrFilter.class, name = "OR"),
@JsonSubTypes.Type(value = AndFilter.class, name = "AND"),
@JsonSubTypes.Type(value = SearchCriteria.class, name = "SEARCH")
})
public interface Filter {
}
@Data
public class AndFilter implements Filter {
private final String type = "AND";
/**
* Значение по которому ищем
*/
@ApiModelProperty(value = "Набор фильтров")
private List<Filter> value;
}
@Data
public class OrFilter implements Filter {
private final String type = "OR";
/**
* Значение по которому ищем
*/
@ApiModelProperty(value = "Набор фильтров")
private List<Filter> value;
}

После того, как фильтры были получены с фронтенда через контроллер, их необходимо передать в сервис, для того, чтобы сформировать объект Specification:

Specification<Workspace> specification = SpecificationCreator.create(filter);
Page<Workspace> results = workspaceRepository.findAll(specification,
pageable);

Преобразование filter в specification происходит в SpecificationCreator:

@UtilityClass
public class SpecificationCreator {
public Specification<Workspace> create(Filter filter) {
if (filter instanceof OrFilter) {
OrFilter orFilter = (OrFilter) filter;
List<Specification<Workspace>> specs =
createInnerSpecifications(orFilter.getValue());
return createOrSpecification(specs);
} else if (filter instanceof AndFilter) {
AndFilter andFilter = (AndFilter) filter;
List<Specification<Workspace>> specs =
createInnerSpecifications(andFilter.getValue());
return createAndSpecification(specs);
} else if (filter instanceof SearchCriteria) {
SearchCriteria searchCriteria = (SearchCriteria) filter;

return createSearhSpecification(searchCriteria);
}
return null;
}
private List<Specification<Workspace>>
createInnerSpecifications(List<Filter> value) {
List<Specification<Workspace>> specs = Lists.newArrayList();
if (value != null && !value.isEmpty()) {
value.forEach(f -> {
Specification<Workspace> specification = create(f);
specs.add(specification);
});
}
return specs;
}
private Specification<Workspace>
createAndSpecification(List<Specification<Workspace>> specs) {
Specification<Workspace> orSpecification =
Specification.where(specs.get(0));
for (int i = 1; i < specs.size(); i++) {
orSpecification = specs.get(i).or(orSpecification);
}
return orSpecification;
}
private Specification<Workspace>
createOrSpecification(List<Specification<Workspace>> specs) {
Specification<Workspace> andSpecification =
Specification.where(specs.get(0));
for (int i = 1; i < specs.size(); i++) {
andSpecification = specs.get(i).and(andSpecification);
}
return andSpecification;
}
private Specification<Workspace> createSearhSpecification(SearchCriteria
searchCriteria) {
return new WorkspaceSpecification(searchCriteria);
}
}

Спецификации соединяются друг с другом по условию AND с помощью метода and() и по условию OR с помощью метода or().

Отдельная спецификация представлена классом WorkspaceSpecification, который наследует Specification<E>

@RequiredArgsConstructor
public class WorkspaceSpecification implements Specification<Workspace> {
private final SearchCriteria criteria;
@Override
public Predicate toPredicate(Root<Workspace> root, CriteriaQuery<?> query,

CriteriaBuilder builder) {
Join<Object, Object> join = null;
if (criteria.getTable() != null) {
join = root.join(criteria.getTable());
}
if (Operation.GR.name()
.equalsIgnoreCase(criteria.getOperation())) {
return builder.greaterThanOrEqualTo(
root.<String> get(criteria.getKey()),
criteria.getValue().toString());
}
else if (Operation.LO.name()
.equalsIgnoreCase(criteria.getOperation())) {
return builder.lessThanOrEqualTo(
root.<String> get(criteria.getKey()),
criteria.getValue().toString());
}
else if (Operation.EQ.name()
.equalsIgnoreCase(criteria.getOperation())) {
if (criteria.getTable() != null) {
if (join.get(criteria.getKey()).getJavaType() == String.class) {
return builder.like(
builder.lower(join.get(criteria.getKey())),
"%" + criteria.getValue().toString().toLowerCase() + "%");
} else {
return builder.equal(join.get(criteria.getKey()),
criteria.getValue());
}
} else {
if (root.get(criteria.getKey()).getJavaType() == String.class) {
return builder.like(
builder.lower(root.get(criteria.getKey())),
"%" + criteria.getValue().toString().toLowerCase() + "%");
} else {
return builder.equal(root.get(criteria.getKey()),
criteria.getValue());
}
}
} else if (Operation.IN.name()
.equalsIgnoreCase(criteria.getOperation())) {
List<String> values =
Lists.newArrayList((List<String>)criteria.getValue());
if (criteria.getTable() != null) {
return join.get(criteria.getKey()).in(values);
} else {
return root.get(criteria.getKey()).in(values);
}
}
return null;
}
}

После того, как спецификация создана, ее можно использовать в репозитории. Для этого репозиторий наследуется от JpaSpecificationExecutor<Workspace>:

public interface WorkspaceRepository extends 
JpaRepository<Workspace,
String>, JpaSpecificationExecutor<Workspace> {
}

Подведем итоги

Преимущества использования Specification:

  • универсальный подход при работе с ui-фильтрами

  • упрощаются репозитории - нет необходимости вообще добавлять методы

  • упрощается логика выбора нужного репозитория

Недостатки реализованного решения:

  • сложно использовать параметризованный код с дженериками

  • хотелось бы чтобы Entity и Repository генерировались автоматически

  • нужен механизм для преобразования конкретного entity в универсальный response.

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

Tags:
Hubs:
Total votes 9: ↑4 and ↓50
Comments3

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS