Построение динамических запросов к базе данных с использованием Spring Data JPA Specifications
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 {
Predicate toPredicate(Root root, CriteriaQuery> query, CriteriaBuilder criteriaBuilder);
}
Основные компоненты:
Root
— позволяет получить доступ к атрибутам сущности.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 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 readers = new ArrayList<>();
}
@Entity
@Data
public class Genre {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "genre")
private List books = new ArrayList<>();
}
@Entity
@Data
public class Reader {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(mappedBy = "readers")
private List borrowedBooks = new ArrayList<>();
}
@Repository
public interface AuthorRepository extends JpaRepository {
}
@Repository
public interface BookRepository extends JpaRepository, JpaSpecificationExecutor {
}
@Repository
public interface GenreRepository extends JpaRepository {
}
@Repository
public interface ReaderRepository extends JpaRepository {
}
@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 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 mapToReaderModels(List 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 hasTitle(String title) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.get("title"), title);
}
public static Specification hasAuthor(String authorName) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.join("author").get("name"), authorName);
}
public static Specification hasGenre(String genreName) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.join("genre").get("name"), genreName);
}
public static Specification isBorrowedBy(String readerName) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.join("readers").get("name"), readerName);
}
public static Specification 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 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 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 помогут сделать их гибкими, читаемыми и легко поддерживаемыми.