Фільтрація даних — одна з найпоширеніших задач при розробці веб-застосунків. Уявімо типову ситуацію: в нас є список користувачів, і потрібно реалізувати пошук за певними параметрами, такими як ім’я, посада чи навички. Причому фільтрація має бути гнучкою: одні параметри можуть передаватися, а інші — ні, а також можливі складні умови, наприклад, пошук за всіма навичками одразу або за будь-якою з вибраних посад.
На перший погляд, стандартні можливості Spring Data JPA мають вирішити цю задачу. Ми можемо:
- Використовувати анотацію @Query, щоб писати SQL-запити вручну.
- Створювати різні методи в репозиторії (findByFirstNameAndPositionIdAndSkillsIdIn тощо).
- Використовувати 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? Задайте своє питання в коментарях нижче! 🤔👇👇