Spring Data JPA Specifications — мощный инструмент для написания динамических запросов в реляционных базах данных. Они позволяют строить сложные SQL-запросы в декларативной форме, комбинируя их с помощью предикатов, таких как AND
, OR
и т.д используя 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/
Добавить комментарий