Построение динамических запросов к базе данных с использованием Spring Data JPA Specifications

от автора

Spring Data JPA Specifications — мощный инструмент для написания динамических запросов в реляционных базах данных. Они позволяют строить сложные SQL-запросы в декларативной форме, комбинируя их с помощью предикатов, таких как ANDOR и т.д используя Java-код. В этой статье мы рассмотрим, зачем нужны Specifications, их преимущества и недостатки, а также лучшие практики для использования.

Зачем появились Specifications?

В реальных приложениях часто требуется фильтровать данные по множеству критериев, которые могут изменяться в зависимости от пользовательского ввода. Традиционные способы обработки таких запросов (например, написание SQL-скриптов вручную или использование @Query с параметрами) могут быть неудобными из-за сложности поддержки и масштабирования.

Specification — это интерфейс, предоставляемый Spring Data JPA, который описывает условия для запросов. Он используется совместно с JpaSpecificationExecutor и предоставляет метод toPredicate, который возвращает объект Predicate. Этот объект преобразуется Hibernate в SQL-запрос.

Основное определение Specification выглядит следующим образом:

@FunctionalInterface public interface Specification<T> {     Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder); }

Основные компоненты:

  • Root<T> — позволяет получить доступ к атрибутам сущности.

  • CriteriaQuery<?> — используется для настройки запроса (например, выборка, сортировка).

  • CriteriaBuilder — предоставляет методы для создания условий, таких как сравнения, логические операции и т. д.

Приведем небольшой пример проекта, для демонстрации базовых возможностей Specifications.

@Entity @Data public class Author {     @Id     @GeneratedValue(strategy = GenerationType.IDENTITY)     private Long id;      private String name;      private String biography;      @OneToMany(mappedBy = "author")     private List<Book> books = new ArrayList<>(); }   @Entity @Data public class Book {     @Id     @GeneratedValue(strategy = GenerationType.IDENTITY)     private Long id;      private String title;      private Double price;      @ManyToOne     private Author author;      @ManyToOne     private Genre genre;      @ManyToMany     @JoinTable(name = "book_readers",             joinColumns = @JoinColumn(name = "book_id"),             inverseJoinColumns = @JoinColumn(name = "reader_id"))     private List<Reader> readers = new ArrayList<>(); }   @Entity @Data public class Genre {     @Id     @GeneratedValue(strategy = GenerationType.IDENTITY)     private Long id;      private String name;      @OneToMany(mappedBy = "genre")     private List<Book> books = new ArrayList<>(); }   @Entity @Data public class Reader {     @Id     @GeneratedValue(strategy = GenerationType.IDENTITY)     private Long id;      private String name;      @ManyToMany(mappedBy = "readers")     private List<Book> borrowedBooks = new ArrayList<>(); }
@Repository public interface AuthorRepository extends JpaRepository<Author, Long> { }  @Repository public interface BookRepository extends JpaRepository<Book, Long>, JpaSpecificationExecutor<Book> { }  @Repository public interface GenreRepository extends JpaRepository<Genre, Long> { }  @Repository public interface ReaderRepository extends JpaRepository<Reader, Long> { }
@Data @Builder public class AuthorModel {     private Long id;     private String name;     private String biography; }  @Data @Builder public class BookModel {     private Long id;     private String title;     private Double price;     private AuthorModel author;     private GenreModel genre;     private List<ReaderModel> readers; }   @Data @Builder public class GenreModel {     private Long id;     private String name; }  @Data @AllArgsConstructor @NoArgsConstructor public class ReaderModel {     private Long id;     private String name;      public static ReaderModel mapToReaderModel(Reader reader) {         return new ReaderModel(reader.getId(), reader.getName());     } }  public class BookMapper {      public static BookModel mapToDto(Book book) {         return BookModel.builder()                 .id(book.getId())                 .title(book.getTitle())                 .price(book.getPrice())                 .author(mapToAuthorModel(book.getAuthor()))                 .genre(mapToGenreModel(book.getGenre()))                 .readers(mapToReaderModels(book.getReaders()))                 .build();     }      private static AuthorModel mapToAuthorModel(Author author) {         return AuthorModel.builder()                 .id(author.getId())                 .name(author.getName())                 .biography(author.getBiography())                 .build();     }      private static GenreModel mapToGenreModel(Genre genre) {         return GenreModel.builder()                 .id(genre.getId())                 .name(genre.getName())                 .build();     }      private static List<ReaderModel> mapToReaderModels(List<Reader> readers) {         return readers.stream()                 .map(ReaderModel::mapToReaderModel)                 .toList();     } }
@Component public class DataInitializer implements CommandLineRunner {      private final BookRepository bookRepository;     private final AuthorRepository authorRepository;     private final GenreRepository genreRepository;     private final ReaderRepository readerRepository;      public DataInitializer(BookRepository bookRepository,                            AuthorRepository authorRepository,                            GenreRepository genreRepository,                            ReaderRepository readerRepository) {         this.bookRepository = bookRepository;         this.authorRepository = authorRepository;         this.genreRepository = genreRepository;         this.readerRepository = readerRepository;     }      @Override     public void run(String... args) {         Genre fantasy = new Genre();         fantasy.setName("Fantasy");         genreRepository.save(fantasy);          Genre mystery = new Genre();         mystery.setName("Mystery");         genreRepository.save(mystery);          Author tolkien = new Author();         tolkien.setName("J.R.R. Tolkien");         tolkien.setBiography("Author of The Lord of the Rings.");         authorRepository.save(tolkien);          Reader reader1 = new Reader();         reader1.setName("Alice");         readerRepository.save(reader1);          Reader reader2 = new Reader();         reader2.setName("Bob");         readerRepository.save(reader2);          Reader reader3 = new Reader();         reader3.setName("Charlie");         readerRepository.save(reader3);          Book book = new Book();         book.setTitle("The Hobbit");         book.setAuthor(tolkien);         book.setGenre(fantasy);         book.setPrice(8.0);         book.getReaders().add(reader1);         book.getReaders().add(reader2);         bookRepository.save(book);          Book book1 = new Book();         book1.setTitle("The Lord of rings");         book1.setAuthor(tolkien);         book1.setGenre(fantasy);         book1.setPrice(2.0);         book1.getReaders().add(reader1);         book1.getReaders().add(reader3);         bookRepository.save(book1);     } }
spring:   datasource:     url: jdbc:h2:mem:testdb     driver-class-name: org.h2.Driver     username: sa     password:   h2:     console:       enabled: true   jpa:     hibernate:       ddl-auto: create     show-sql: true

И, наконец, добавим класс для Specifications, создав несколько методов для фильтрации по имени автора, жанру, цене и т.д.

public class BookSpecification {      public static Specification<Book> hasTitle(String title) {         return (root, query, criteriaBuilder) ->                 criteriaBuilder.equal(root.get("title"), title);     }      public static Specification<Book> hasAuthor(String authorName) {         return (root, query, criteriaBuilder) ->                 criteriaBuilder.equal(root.join("author").get("name"), authorName);     }      public static Specification<Book> hasGenre(String genreName) {         return (root, query, criteriaBuilder) ->                 criteriaBuilder.equal(root.join("genre").get("name"), genreName);     }      public static Specification<Book> isBorrowedBy(String readerName) {         return (root, query, criteriaBuilder) ->                 criteriaBuilder.equal(root.join("readers").get("name"), readerName);     }      public static Specification<Book> priceBetween(String minPrice, String maxPrice) {         return (root, query, criteriaBuilder) ->                 criteriaBuilder.between(root.get("price"), minPrice, maxPrice);     } }

Применим Specifications в контроллере.

@RestController @RequestMapping("/books") public class BookController {      private final BookRepository bookRepository;      public BookController(BookRepository bookRepository) {         this.bookRepository = bookRepository;     }      @GetMapping("/search")     public List<BookModel> searchBooks(             @RequestParam(required = false) String title,             @RequestParam(required = false) String author,             @RequestParam(required = false) String genre,             @RequestParam(required = false) String reader,             @RequestParam(required = false) String priceFrom,             @RequestParam(required = false) String priceTo     ) {         Specification<Book> spec = Specification.where(null);          if (title != null) spec = spec.and(BookSpecification.hasTitle(title));         if (author != null) spec = spec.and(BookSpecification.hasAuthor(author));         if (genre != null) spec = spec.and(BookSpecification.hasGenre(genre));         if (reader != null) spec = spec.and(BookSpecification.isBorrowedBy(reader));         if (priceFrom != null && priceTo != null) spec = spec.and(BookSpecification.priceBetween(priceFrom, priceTo));         var books = bookRepository.findAll(spec);         return books.stream()                 .map(BookMapper::mapToDto)                 .toList();     } }

Таким образом, в контроллере мы создаем пустую Specification, и по мере передачи параметров в метод searchBooks она будет обрастать новыми и новыми условиями.

К примеру, при вызове http://localhost:8080/books/search?reader=Alice мы получим все книги, которые прочитала Alice

[ { "id": 1, "title": "The Hobbit", "price": 8, "author": { "id": 1, "name": "J.R.R. Tolkien", "biography": "Author of The Lord of the Rings." }, "genre": { "id": 1, "name": "Fantasy" }, "readers": [ { "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" } ] }, { "id": 2, "title": "The Lord of rings", "price": 2, "author": { "id": 1, "name": "J.R.R. Tolkien", "biography": "Author of The Lord of the Rings." }, "genre": { "id": 1, "name": "Fantasy" }, "readers": [ { "id": 1, "name": "Alice" }, { "id": 3, "name": "Charlie" } ] } ]

А при вызове http://localhost:8080/books/search?reader=Bob&genre=Fantasy, получим книги, которые прочитал Bob в жанре Fantasy:

[ { "id": 1, "title": "The Hobbit", "price": 8, "author": { "id": 1, "name": "J.R.R. Tolkien", "biography": "Author of The Lord of the Rings." }, "genre": { "id": 1, "name": "Fantasy" }, "readers": [ { "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" } ] } ]

А добавив условие, что книга должна стоить от 9 до 100 долларов, http://localhost:8080/books/search?reader=Bob&genre=Fantasy&priceFrom=9&priceTo=100, мы получим пустой Json, так как книг удовлетворяющих данному условию не существует.

Таким образом, к преимуществам Specifications можно отнести:

1. Модульность

Каждое условие фильтрации может быть представлено как отдельный объект Specification, что упрощает повторное использование и тестирование.

2. Динамичность

Запросы могут быть составлены из произвольного числа условий, заданных во время выполнения программы.

3. Читаемость

Код с Specifications выглядит более декларативно и проще для понимания, чем сложный JPQL или SQL.

4. Интеграция

Specifications полностью совместимы с остальными инструментами Spring Data JPA.

А к минусам:

1. Сложность для новичков

Порог входа для начинающих разработчиков может быть высоким из-за необходимости понимания принципов работы Criteria API.

2. Увеличение количества классов

Каждое условие обычно представлено отдельным классом, что может привести к большому количеству мелких файлов.

3. Ограничения в сложных сценариях

Для очень сложных запросов может потребоваться больше усилий, чем при написании нативного SQL или JPQL.

Заключение

Specifications — это мощный инструмент для написания гибких запросов в Spring Data JPA. Они значительно упрощают работу с фильтрацией данных, заменяя громоздкие методы репозитория и улучшая читаемость кода. Однако их использование требует внимательности и соблюдения лучших практик, чтобы избежать роста сложности проекта.

Если ваш проект активно использует динамические запросы, Specifications помогут сделать их гибкими, читаемыми и легко поддерживаемыми.


ссылка на оригинал статьи https://habr.com/ru/articles/870698/