Общая информация
В своей практике мы можем сталкиваться с хранением и поиском информации задаваемой в интревальном формате. Например: срок действия акций, тарифы на доставку и прочее

Задача
Для рассмотрения особенностей реализации работы с интервальными типами данных реализуем хранение ставок транспортного налога для легковых автомобилей в 1 регионе. Ставки налогообложения для простоты возьмьем с сайта https://www.voaspb.ru/servisy/transportnyj-nalog

Для реализациий классической схемы в Spring JPA нам потребуется:
Таблица для хранения ставок
Entity
Repository
В наших примерах будем рассматривать получение данных следущими способами
SQL-запрос
Specification
Реализация без интервалов
Таблица БД
Простая реализация таблицы для хранения тарифов в БД будет выглядеть приблизительно так:
CREATE TABLE tax_rate_simple
(
id bigserial NOT NULL,
lower_bound integer NOT NULL,
upper_bound integer,
rate integer NOT NULL,
CONSTRAINT tax_rate_simple_pk PRIMARY KEY (id),
CONSTRAINT tax_rate_simple_upper_bound_unq UNIQUE (lower_bound),
CONSTRAINT rate_chk CHECK (rate > 0),
CONSTRAINT upper_bound_chk CHECK (upper_bound IS NULL OR upper_bound > lower_bound)
);Entity
Соответственно Entity у нас достаточно примитивная, поскольку мы используем стандарные решения:
@Getter
@Setter
@Entity
@Table(name = "tax_rate_simple")
public class TaxRateSimple {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
@NotNull
@Column(name = "lower_bound", nullable = false)
private Integer lowerBound;
@Column(name = "upper_bound")
private Integer upperBound;
@NotNull
@Positive
@Column(name = "rate", nullable = false)
private Integer rate;
}SQL-запрос и Repository
Составим наш SQL-запрос для поиска ставки:
SELECT * FROM tax_rate_simple
WHERE lower_bound< :power
AND (upper_bound IS NULL OR upper_bound>= :power)Добавим этот запрос для получения данных через repository :
public interface TaxRateSimpleRepository extends Repository<TaxRateSimple, Long>, JpaSpecificationExecutor<TaxRateSimple> {
@Query(nativeQuery = true,
value = "SELECT * FROM tax_rate_simple WHERE lower_bound< :power AND (upper_bound IS NULL OR upper_bound>= :power)")
Optional<TaxRateSimple> findByPower(@Param("power") Integer power);
}Как видим здесь все прозрачно и понятно.
Specification
Если вы до сих пор решаете вопрос с динамическими фильтрами путем игрищ со строками, то, возможно, вам стоит посмотреть в сторону Spring Data JPA Specifications. Это мощный инструмент для динамического построения запросов к БД. Основной идеей которого является просто создание правильного предиката или набора предикатов
Сформируем наш предикат, который будет идентичен sql-запросу:
public final class TaxRateSimplePredicateUtils {
private TaxRateSimplePredicateUtils() {
}
public static Predicate getPowerPredicate(From<?, TaxRateSimple> from, CriteriaBuilder builder, int power) {
return builder.and(
builder.lessThan(from.get("lowerBound"), power),
builder.or(
builder.isNull(from.get("upperBound")),
builder.greaterThanOrEqualTo(from.get("upperBound"), power)
)
);
}
}
Получение данных через Specification будет выглядить следующим образом:
Specification<TaxRateSimple> specification = (root, criteriaQuery, criteriaBuilder) ->
TaxRateSimplePredicateUtils.getPowerPredicate(root,criteriaBuilder, 150);
Optional<TaxRateSimple> result = repository.findOne(specification);Реализация на интервальных типах данных
В этом случае все несколько сложнее по реализации и требует чуть большего кругозора и знаний от разработчика. Однако, почти все необходимые знания есть у современного java-разработчика с опытом от 5 лет.
Таблица БД
Таблица для хранения ставок будет реализовываться с использованием такого типа танных как int4range:
CREATE TABLE tax_rate_range
(
id bigserial NOT NULL,
range int4range NOT NULL,
rate integer NOT NULL,
CONSTRAINT tax_rate_range_pk PRIMARY KEY (id),
CONSTRAINT tax_rate_range_rate_chk CHECK (rate > 0),
CONSTRAINT tax_rate_range_range_unq EXCLUDE USING GIST (range WITH &&)
);Обратите внимание на ограничение tax_rate_range_range_unq. Оно позволяет нам валидировать интервалы на пересечение. Подобное ограничение при реализации в "варианте 1" было бы весьма хлопотно.
Entity
Для работы с нестандартными типами данных обычно используют готовую библиотеку от Vlad Mihalcea 'hypersistence-utils-hibernate' :
<dependency>
<groupId>io.hypersistence</groupId>
<artifactId>hypersistence-utils-hibernate-63</artifactId>
<version>3.13.2</version>
</dependency>Наш класс будет иметь вид:
@Accessors(chain=true)
@Getter
@Setter
@Entity
@Table(name = "tax_rate_range")
public class TaxRateRange {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
@Column(name = "range", columnDefinition = "int4range", nullable = false)
private Range<Integer> range;
@NotNull
@Column(name = "rate", nullable = false)
private Integer rate;
}Как видим тут все выглядит волне традиционно
SQL-запрос и Repository
В отличии от предыдущей реализации в случае использования интервальных типов данных запрос будет выглядить гораздо более стройно:
SELECT * FROM tax_rate_range WHERE range @> :power;Для репозитория практически ничего не изменится, поменяется только запрос:
public interface TaxRateRangeRepository extends JpaRepository<TaxRateRange, Long>, JpaSpecificationExecutor<TaxRateRange> {
@Query(nativeQuery = true,
value = "SELECT * FROM tax_rate_range WHERE range @> :power")
Optional<TaxRateRange> findByPower(@Param("power") Integer power);
}Specification
А вот тут, как раз начинается самая интересная часть. Как вы, наверное, догадались нам нужно создать предикат, а стандартное решение jakarta.persistence.criteria.CriteriaBuilder ничего прямого к использованию не предлагает.
Что же нам делать? Все просто, нам нужно создать и зарегистрировать новую функцию.
Существует несколько вариантов объявления и регистрации функции, но я сделаю самый примитивный и простой вариант - через расширение диалекта:
public class PGDialect extends PostgresPlusDialect {
public static final String INT4RANGE_CONTAIN = "int4range_contain";
public PGDialect() {
super();
}
@Override
public void initializeFunctionRegistry(FunctionContributions functionContributions) {
super.initializeFunctionRegistry(functionContributions);
SqmFunctionRegistry functionRegistry = functionContributions.getFunctionRegistry();
BasicTypeRegistry basicTypeRegistry = functionContributions.getTypeConfiguration().getBasicTypeRegistry();
BasicType<Boolean> booleanType = basicTypeRegistry.resolve(StandardBasicTypes.BOOLEAN);
functionRegistry.registerPattern(INT4RANGE_CONTAIN, "( ?1::int4range @> ?2::integer)", booleanType);
}
}Подключим наш диалект к Hibernate.
spring:
jpa:
database: postgresql
open-in-view: false
show-sql: true
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.cafejava.demo.range.dialect.PGDialect
Итак, полдела сделано. Будем теперь создавать предикат:
public final class TaxRateRangePredicateUtils {
private TaxRateRangePredicateUtils() {
}
public static Predicate getPowerPredicate(From<?, TaxRateRange> from, CriteriaBuilder builder, int power) {
return builder.isTrue(
builder.function(INT4RANGE_CONTAIN, Boolean.class, from.get("range"), builder.literal(power))
);
}
}А вот получение данных через specification у нас практически идентичное, что и вслучае без интервалов :
Specification<TaxRateRange> specification = (root, criteriaQuery, criteriaBuilder) ->
TaxRateRangePredicateUtils.getPowerPredicate(root,criteriaBuilder,150);
Optional<TaxRateRange> result = repository.findOne(specification);Заключение
Как видите подключение и использование интервальных типов данных не является чем-то сверхсложным и предоставляет дополнительный функционал разработчику, который был бы нереализуем или очень сложно реализуем через стандартные типы.
