02.06.2025
8 хвилин читання

Spring Data JPA Specifications: потужний інструмент для динамічних фільтрів

Динамічне фільтрування у Spring Data JPA Ч1

Фільтрація даних — одна з найпоширеніших задач при розробці веб-застосунків. Уявімо типову ситуацію: в нас є список користувачів, і потрібно реалізувати пошук за певними параметрами, такими як ім’я, посада чи навички. Причому фільтрація має бути гнучкою: одні параметри можуть передаватися, а інші — ні, а також можливі складні умови, наприклад, пошук за всіма навичками одразу або за будь-якою з вибраних посад.

На перший погляд, стандартні можливості Spring Data JPA мають вирішити цю задачу. Ми можемо:

  1. Використовувати анотацію @Query, щоб писати SQL-запити вручну.
  2. Створювати різні методи в репозиторії (findByFirstNameAndPositionIdAndSkillsIdIn тощо).
  3. Використовувати Criteria API для динамічної побудови запитів.

Проте всі ці підходи мають значні недоліки:

  • Запити в @Query можуть стати надто великими та складними для підтримки. Наприклад, якщо користувач може фільтрувати за ім’ям, посадою та навичками, доведеться писати WHERE-умови з CASE WHEN або COALESCE, щоб враховувати всі можливі комбінації параметрів. А якщо таких параметрів більше ніж 20?
  • Методи репозиторію стають занадто специфічними (що більше параметрів, то більше різних комбінацій методів). Тут так само маємо проблему із масштабуванням фільтрів.
  • Criteria API складний і громіздкий, що робить код менш читабельним.
  • Проблеми з продуктивністю – якщо неправильно використовувати динамічні SQL-запити у @Query або Criteria API, це може призвести до неефективного виконання запитів, особливо якщо використовуються JOIN або підзапити.

Але є кращий підхід — Spring Data JPA Specifications. Це потужний механізм, який дозволяє гнучко будувати запити до бази, не засмічуючи код зайвими умовами та SQL-скриптами. У цій статті ми розглянемо, чому специфікації — це найкраще рішення для динамічної фільтрації, та покроково реалізуємо пошук користувачів із фільтрацією за кількома параметрами.

Хочете опанувати професію Java Developer? Приєднуйтесь до програми “Від 0 до Strong Java Junior за 12 місяців”. Скористайтеся вигідною пропозицією від FoxmindEd!
Зареєструватись

Що таке 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, які знайдено в підзапиті.

Це гарантує, що користувачі будуть знайдені тільки якщо у них є всі передані навички.

Підпишіться на наш Ютуб-канал! Корисні відео для програмістів чекають на вас! YouTube
Оберіть свій курс програмування! Шлях до кар’єри програміста починається тут! Подивитись

Чому ми розділили специфікації на окремі класи?

Замість того щоб створювати один клас UserSpecification, який містить усі можливі критерії фільтрації, ми розбили специфікації на окремі класи. Це дає низку переваг:

  1. Гнучкість – кожен фільтр є окремим компонентом, який можна легко додати або прибрати без змін у репозиторії чи сервісі.
  2. Розширюваність – якщо потрібно додати новий фільтр, достатньо створити новий клас специфікації, наприклад, AgeSpecificationProvider, без змін у вже існуючих частинах коду. А також ми не перевантажуємо UserSpecification купою методів із фільтруванням.
  3. Можливість довільного комбінування – ми можемо застосовувати будь-яку кількість фільтрів у запиті або взагалі їх не використовувати, залежно від переданих параметрів.

Підсумки першої частини

У цій частині ми розглянули, як JPA Specifications допомагає створювати гнучкі та розширювані запити до бази даних. Розділення специфікацій на окремі класи дозволяє легко додавати нові фільтри без змін у вже існуючому коді. У другій частині ми розглянемо SpecificationManager, SpecificationProvider, покажемо, як керувати специфікаціями через сервіс, а також подивимося на результати запитів.

FAQ
JPA Specifications - це механізм у Spring Data JPA, який дозволяє динамічно будувати запити до бази даних без необхідності створювати складні SQL-запити або десятки методів у репозиторії. Вони базуються на Criteria API, але значно спрощують його використання, забезпечуючи гнучку та масштабовану фільтрацію.
Традиційні методи, як-от написання запитів через @Query, створення методів типу findBy..., або використання Criteria API, мають суттєві недоліки: низька читабельність, складність масштабування, дублювання коду та потенційні проблеми з продуктивністю при складних запитах.
Такий підхід забезпечує гнучкість, можливість легко додавати або прибирати фільтри, не змінюючи решту логіки. Це також підвищує читабельність, спрощує тестування й дозволяє легко комбінувати будь-які фільтри залежно від потреб.
Фільтрація за посадами використовує просту перевірку IN (тобто користувач має відповідати хоча б одній з переданих посад). Фільтрація за навичками складніша - вона вимагає, щоб користувач мав усі передані навички, що реалізується через підзапит з HAVING COUNT(DISTINCT).
Якщо користувач вводить одне слово - система шукає збіг у імені або прізвищі. Якщо введено два слова - система шукає точний збіг імені та прізвища одночасно. Це дозволяє забезпечити гнучкий пошук без складних умов у запиті.
Так. Оскільки JPA Specifications інтегруються з інтерфейсом Pageable, ви можете застосовувати фільтрацію разом із сортуванням і розбивкою результатів на сторінки, що особливо корисно при роботі з великими наборами даних.

Хочете дізнатися більше про Spring Data JPA Specifications? Задайте своє питання в коментарях нижче! 🤔👇👇

Додати коментар

Ваш імейл не буде опубліковано. Обов'язкові поля відзначені *

Зберегти моє ім'я, імейл та адресу сайту у цьому браузері для майбутніх коментарів