02.06.2025
8 минут чтения

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

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

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

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

  1. Использовать аннотацию @Query, чтобы писать SQL-запросы вручную.
  2. Создавать различные методы в репозитории (findByFirstNameAndPositionIdAndPositionIdAndSkillsIdIn и т.д.).
  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? Задайте свой вопрос в комментариях ниже! 🤔👇👇👇

Добавить комментарий

Ваш имейл не будет опубликован. Обязательные поля отмечены *

Сохранить моё имя, имейл и адрес сайта в этом браузере для будущих комментариев