Делаем динамический отчет средствами JPA Criteria.Api -1


Очень часто в корпоративной разработке происходит диалог:

image

Сталкивались?

В данной статье мы рассмотрим, каким образом можно сделать запросы по таблице с изменяющимся списком критериев в среде Spring+JPA/Hibernate без прикручивания дополнительных библиотек.

Основных вопросов всего два:

  • Как динамически собрать SQL-запрос
  • Как передать условия для формирования этого запроса

Для сборки запросов JPA, начиная с 2.0 (а это было очень и очень давно), предлагает решение – Criteria Api, продукты которого – объекты Specification, мы можем далее передавать в параметры методов JPA-репозиториев.

Specification – итоговые ограничения запроса, содержит объекты Predicate как условия WHERE, HAVING. Предикаты – конечные выражения, которые могут принимать значения true или false.

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

public class SearchCriteria{
    //Сравниваемое поле
    String key;
    //Оператор сравнения(больше, меньше и пр.)
    SearchOperator operator;
    //Значение для сравнения
    String value;
    //Тип примыкания дочерних выражений
    private JoinType joinType;
    //Список дочерних выражений
    private List<SearchCriteria> criteria;
}

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

/**
* Построитель спецификаций
*/
public class JpaSpecificationsBuilder<T> {
 
    // список возможных операций
    private Map<SearchOperation, PredicateBuilder> predicateBuilders = Stream.of(
            new AbstractMap.SimpleEntry<SearchOperation,PredicateBuilder>(SearchOperation.EQ,new EqPredicateBuilder()),
            new AbstractMap.SimpleEntry<SearchOperation,PredicateBuilder>(SearchOperation.MORE,new MorePredicateBuilder()),
            new AbstractMap.SimpleEntry<SearchOperation,PredicateBuilder>(SearchOperation.MOREQ,new MoreqPredicateBuilder()),
            new AbstractMap.SimpleEntry<SearchOperation,PredicateBuilder>(SearchOperation.LESS,new LessPredicateBuilder()),
            new AbstractMap.SimpleEntry<SearchOperation,PredicateBuilder>(SearchOperation.LESSEQ,new LesseqPredicateBuilder())
    ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
 
    /**
     * Строит спецификацию по поданным условиям
     */
    public Specification<T> buildSpecification(SearchCriteria criterion){
        return (root, query, cb) -> buildPredicate(root,cb,criterion);
    }
     
    /**
    * Объединяет спецификации
    */
    public Specification<T> mergeSpecifications(List<Specification> specifications, JoinType joinType) {
        return (root, query, cb) -> {
            List<Predicate> predicates = new ArrayList<>();
 
            specifications.forEach(specification -> predicates.add(specification.toPredicate(root, query, cb)));
 
            if(joinType.equals(JoinType.AND)){
                return cb.and(predicates.toArray(new Predicate[0]));
            }
            else{
                return cb.or(predicates.toArray(new Predicate[0]));
            }
 
        };
    }
}

Чтобы не городить огромный if для операций сравнения, реализуем Map операторов вида <Операция, Оператор>. Оператор должен уметь построить одиночный предикат. Приведу пример операции ">", остальные пишем по аналогии:

public class EqPredicateBuilder implements PredicateBuilder {
    @Override
    public SearchOperation getManagedOperation() {
        return SearchOperation.EQ;
    }
 
    @Override
    public Predicate getPredicate(CriteriaBuilder cb, Path path, SearchCriteria criteria) {
        if(criteria.getValue() == null){
            return cb.isNull(path);
        }
 
        if(LocalDateTime.class.equals(path.getJavaType())){
            return cb.equal(path,LocalDateTime.parse(criteria.getValue()));
        }
        else {
            return cb.equal(path, criteria.getValue());
        }
    }
}

Теперь осталось реализовать рекурсивный разбор нашей структуры SearchCriteria. Отмечу, метод buildPath, который по Root – области определения объекта T будет находить путь к полю, на которое ссылается SearchCriteria.key:

private Predicate buildPredicate(Root<T> root, CriteriaBuilder cb, SearchCriteria criterion) {
    if(criterion.isComplex()){
        List<Predicate> predicates = new ArrayList<>();
        for (SearchCriteria subCriterion : criterion.getCriteria()) {
            // стоит реализовать ограничитель глубины рекурсии, но мы ленивые и не будем этого делать
            predicates.add(buildPredicate(root,cb,subCriterion));
        }
        if(JoinType.AND.equals(criterion.getJoinType())){
            return cb.and(predicates.toArray(new Predicate[0]));
        }
        else{
            return cb.or(predicates.toArray(new Predicate[0]));
        }
    }
    return predicateBuilders.get(criterion.getOperation()).getPredicate(cb,buildPath(root, criterion.getKey()),criterion);
}
 
private static Path buildPath(Root<?> root, String key) {
 
    if (!key.contains(".")) {
        return root.get(key);
    } else {
        String[] path = key.split("\\.");
        // Если в нашем выражении присутствует символ ".", постепенно проходим иерархию Root-а до конечного элемента.
        Join<Object, Object> join = root.join(path[0]);
        for (int i = 1; i < path.length - 1; i++) {
            join = join.join(path[i]);
        }
        return join.get(path[path.length - 1]);
    }
 
}

Напишем тестовый кейс для нашего построителя:

//Класс Entity
@Entity
public class ExampleEntity {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    public int value;
 
    public ExampleEntity(int value){
        this.value = value;
    }
 
}
 
...
 
// репозиторий
@Repository
public interface ExampleEntityRepository extends JpaRepository<ExampleEntity,Long>, JpaSpecificationExecutor<ExampleEntity> {
}
 
...
 
// тест
/*
ваши настройки запуска
*/
public class JpaSpecificationsTest {
 
    @Autowired
    private ExampleEntityRepository exampleEntityRepository;
 
    @Test
    public void getWhereMoreAndLess(){
        exampleEntityRepository.save(new ExampleEntity(3));
        exampleEntityRepository.save(new ExampleEntity(5));
        exampleEntityRepository.save(new ExampleEntity(0));
 
        SearchCriteria criterion = new SearchCriteria(
                null,null,null,
                Arrays.asList(
                        new SearchCriteria("value",SearchOperation.MORE,"0",null,null),
                        new SearchCriteria("value",SearchOperation.LESS,"5",null,null)
                ),
                JoinType.AND
        );
        assertEquals(1,exampleEntityRepository.findAll(specificationsBuilder.buildSpecification(criterion)).size());
    }
 
}

Итого, мы научили наше приложение разбирать логическое выражение, используя Criteria.API. Набор операций в текущей реализации ограничен, но читатель может самостоятельно реализовать нужные ему. На практике решение применено, но пользователям не интересно(у них лапки) строить выражение глубже первого уровня рекурсии.

Реализованную версию вы можете найти в моем репозитории на Github

Более подробно о Criteria.Api можно почитать здесь.




К сожалению, не доступен сервер mySQL