Фильтрация данных — одна из самых распространенных задач при разработке веб-приложений. Представим типичную ситуацию: у нас есть список пользователей, и нужно реализовать поиск по определенным параметрам, таким как имя, должность или навыки. Причем фильтрация должна быть гибкой: одни параметры могут передаваться, а другие — нет, а также возможны сложные условия, например, поиск по всем навыкам сразу или по любой из выбранных должностей.
На первый взгляд, стандартные возможности Spring Data JPA должны решить эту задачу. Мы можем:
- Использовать аннотацию @Query, чтобы писать SQL-запросы вручную.
- Создавать различные методы в репозитории (findByFirstNameAndPositionIdAndPositionIdAndSkillsIdIn и т.д.).
- Использовать Criteria API для динамического построения запросов.
Однако все эти подходы имеют значительные недостатки:
- Запросы в @Query могут стать слишком большими и сложными для поддержки. Например, если пользователь может фильтровать по имени, должности и навыкам, придется писать WHERE-условия с CASE WHEN или COALESCE, чтобы учитывать все возможные комбинации параметров. А если таких параметров больше чем 20?
- Методы репозитория становятся слишком специфическими (чем больше параметров, тем больше различных комбинаций методов). Здесь так же имеем проблему с масштабированием фильтров.
- Criteria API сложный и громоздкий, что делает код менее читабельным.
- Проблемы с производительностью — если неправильно использовать динамические SQL-запросы в @Query или Criteria API, это может привести к неэффективному выполнению запросов, особенно если используются JOIN или подзапросы.
Но есть лучший подход — Spring Data JPA Specifications. Это мощный механизм, который позволяет гибко строить запросы к базе, не засоряя код лишними условиями и SQL-скриптами. В этой статье мы рассмотрим, почему спецификации — это лучшее решение для динамической фильтрации, и пошагово реализуем поиск пользователей с фильтрацией по нескольким параметрам.
Что такое JPA Specifications?
JPA Specifications — это мощный механизм в Spring Data JPA, который позволяет строить динамические запросы без написания SQL или создания множества методов в репозитории. Основная идея заключается в использовании отдельных спецификаций для каждого критерия поиска, которые можно комбинировать и применять через JpaSpecificationExecutor.
Спецификации основаны на Criteria API, но значительно упрощают его использование. Они формируют Predicate, который можно сочетать с другими условиями через AND или OR, позволяя строить сложные запросы без дублирования кода и лишних if-else проверок. Поскольку спецификации работают независимо, добавление нового фильтра не требует изменений в уже существующих запросах.
Еще одно преимущество — поддержка сортировки и пагинации. Поскольку спецификации интегрируются с Pageable, их можно использовать для эффективной работы с большими объемами данных, управляя как фильтрацией, так и отображением результатов.
Как это работает?
JPA Specifications использует интерфейс Specification<T>, который содержит один метод:
@FunctionalInterface
public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);
}
- root — позволяет получить доступ к атрибутам таблицы (User, Position, Skill и т.д.).
- query — представляет сам SQL-запрос.
- criteriaBuilder — используется для создания условий (WHERE, LIKE, JOIN и т.д.).
Пример простой спецификации
Фильтрация пользователей по имени (firstName):
public class UserSpecification {
public static Specification<User> hasFirstName(String firstName) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.get("firstName"), firstName);
}
}
Эта спецификация генерирует условие WHERE first_name = ?, которое JPA включает в запрос к базе данных. Такой подход упрощает поддержку и расширение кода, поскольку позволяет легко добавлять новые критерии фильтрации без необходимости изменять репозиторий или сервис.
Модель даних для прикладу
Чтобы продемонстрировать использование JPA Specifications, мы рассмотрим пример, в котором нужно фильтровать пользователей по имени, должности и навыкам. Для этого используем следующую модель данных, состоящую из трех сущностей: пользователей (User), должностей (Position) и навыков (Skill).
@Entity
@Table(name = "users")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
@ManyToOne
@JoinColumn(name = "position_id")
private Position position;
@ManyToMany
@JoinTable(name = "user_skills",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "skill_id"))
private Set<Skill> skills = new HashSet<>();
}
@Entity
@Table(name = "skills")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Skill {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String name;
}
@Entity
@Table(name = "positions")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Position {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String name;
}
Тестові дані для перевірки фільтраці
INSERT INTO positions (id, name) VALUES
(1, 'developer'),
(2, 'qa'),
(3, 'pm');
INSERT INTO skills (id, name) VALUES
(1, 'Java'),
(2, 'JavaScript'),
(3, 'Go'),
(4, 'Python'),
(5, 'TypeScript');
INSERT INTO users (id, first_name, last_name, position_id) VALUES
(1, 'Jonson', 'Li', 1), -- Developer
(2, 'Tomas', 'Jonson', 2), -- QA
(3, 'Emily', 'Clark', 3), -- PM (без навыков)
(4, 'David', 'Brown', 1), -- Developer
(5, 'Sophia', 'Miller', 2), -- QA
(6, 'Michael', 'Smith', 1); -- Developer
INSERT INTO user_skills (user_id, skill_id) VALUES
(1, 1), (1, 2), (1, 3), -- Jonson Li: Java, JavaScript, Go
(2, 2), (2, 4), -- Tomas Jonson: JavaScript, Python
(4, 1), (4, 3), (4, 5), -- David Brown: Java, Go, TypeScript
(5, 4), -- Sophia Miller: Python
(6, 1), (6, 2), (6, 4), (6, 5); -- Michael Smith: Java, JavaScript, Python, TypeScript
Эта структура позволит нам протестировать работу спецификаций в различных сценариях. Далее мы рассмотрим, как создавать спецификации для поиска пользователей по различным критериям.
Реализация спецификаций
Перед тем как перейти к реализации, важно отметить, что в этом коде не реализованы обработка ошибок и валидация входных параметров. Например, если пользователь передаст некорректное значение для числа (Long::parseLong), это может вызвать ошибку NumberFormatException. Мы сознательно пропустили эти проверки, чтобы не усложнять код, поскольку основная цель — продемонстрировать работу спецификаций. В реальном проекте нужно обязательно проверять входные данные, обрабатывать ошибки и возвращать соответствующие HTTP-статусы в случае некорректных запросов.
Фильтрация по имени
В этой спецификации реализован поиск пользователя по полю firstName или lastName. Если пользователь передает одно слово, мы ищем его и в имени, и в фамилии (логика OR). Если передано два слова (имя + фамилия), то ищем точное совпадение (логика AND).
@Component
public class NameSpecificationProvider implements SpecificationProvider<User> {
private static final String FILTER_KEY = "name";
private static final String FIRST_NAME_FIELD = "firstName";
private static final String LAST_NAME_FIELD = "lastName";
@Override
public Specification<User> getSpecification(List<String> params) {
return (root, query, cb) -> {
if (params == null || params.isEmpty() || params.get(0).isEmpty()) {
return cb.conjunction();
}
String name = params.get(0).trim();
String[] parts = name.split(" ");
if (parts.length == 2) {
// Если пользователь передал имя и фамилию через пробел
return cb.and(
cb.equal(root.get(FIRST_NAME_FIELD), parts[0]),
cb.equal(root.get(LAST_NAME_FIELD), parts[1])
);
}
// Если пользователь передал только одно слово, ищем в firstName или lastName
return cb.or(
cb.equal(root.get(FIRST_NAME_FIELD), name),
cb.equal(root.get(LAST_NAME_FIELD), name)
);
};
}
@Override
public String getFilterKey() {
return FILTER_KEY;
}
}
Фильтрация по должности
В этой спецификации мы получаем список идентификаторов должностей (positionId). Поскольку один пользователь имеет только одну должность, мы просто проверяем, входит ли его position_id в переданный список. Используем IN, потому что поиск выполняется по логике OR — если пользователь соответствует хотя бы одной из указанных должностей, он должен попасть в результаты.
@Component
public class PositionSpecificationProvider implements SpecificationProvider<User> {
private static final String FILTER_KEY = "position";
@Override
public Specification<User> getSpecification(List<String> params) {
return (root, query, cb) -> {
if (params == null || params.isEmpty()) {
return cb.conjunction();
}
List<Long> positionIds = params.stream()
.map(Long::parseLong)
.toList();
return root.get("position").get("id").in(positionIds);
};
}
@Override
public String getFilterKey() {
return FILTER_KEY;
}
}
Фильтрация по навыкам
Здесь используется более сложная логика. Пользователь может иметь несколько навыков, поэтому простой IN здесь не подходит. Например, если в запросе передано stack=1,2, то пользователь должен иметь оба навыка (Java и JavaScript). Это означает, что мы должны искать пользователей, у которых в таблице user_skills есть все переданные скиллы одновременно.
Поскольку стандартный IN ищет совпадение хотя бы по одному значению, нам приходится использовать подзапрос (Subquery) с HAVING COUNT(DISTINCT), чтобы гарантировать, что у пользователя есть все переданные навыки.
@Component
public class SkillSpecificationProvider implements SpecificationProvider<User> {
private static final String FILTER_KEY = "skills";
private static final String ID = "id";
@Override
public Specification<User> getSpecification(List<String> params) {
return (root, query, cb) -> {
if (params == null || params.isEmpty()) {
return cb.conjunction();
}
List<Long> skillIds = params.stream()
.map(Long::parseLong)
.toList();
Subquery<Long> subquery = query.subquery(Long.class);
Root<User> subRoot = subquery.from(User.class);
Join<User, Skill> userSkills = subRoot.join("skills");
subquery.select(subRoot.get(ID))
.where(userSkills.get(ID).in(skillIds))
.groupBy(subRoot.get(ID))
.having(cb.equal(cb.countDistinct(userSkills.get(ID)), skillIds.size()));
return root.get(ID).in(subquery);
};
}
@Override
public String getFilterKey() {
return FILTER_KEY;
}
}
Если передан список skillIds, мы строим подзапрос:
- Находим всех пользователей, которые имеют хотя бы один из переданных навыков.
- Используем HAVING COUNT(DISTINCT skill_id) = ?, чтобы найти только тех, у кого есть все эти навыки.
- Используем IN, чтобы ограничить основной запрос теми user.id, которые найдены в подзапросе.
Это гарантирует, что пользователи будут найдены только если у них есть все переданные навыки.
Почему мы разделили спецификации на отдельные классы?
Вместо того чтобы создавать один класс UserSpecification, который содержит все возможные критерии фильтрации, мы разбили спецификации на отдельные классы. Это дает ряд преимуществ:
- Гибкость — каждый фильтр является отдельным компонентом, который можно легко добавить или убрать без изменений в репозитории или сервисе.
- Расширяемость — если нужно добавить новый фильтр, достаточно создать новый класс спецификации, например AgeSpecificationProvider, без изменений в уже существующих частях кода. А также мы не перегружаем UserSpecification кучей методов с фильтрацией.
- Возможность произвольного комбинирования — мы можем применять любое количество фильтров в запросе или вообще их не использовать, в зависимости от переданных параметров.
Итоги первой части
В этой части мы рассмотрели, как JPA Specifications помогает создавать гибкие и расширяемые запросы к базе данных. Разделение спецификаций на отдельные классы позволяет легко добавлять новые фильтры без изменений в уже существующем коде. Во второй части мы рассмотрим SpecificationManager, SpecificationProvider, покажем, как управлять спецификациями через сервис, а также посмотрим на результаты запросов.
Хотите узнать больше о Spring Data JPA Specifications? Задайте свой вопрос в комментариях ниже! 🤔👇👇👇