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

993a62b86108003fd506fa7fdc591aed

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 помогут сделать их гибкими, читаемыми и легко поддерживаемыми.

© Habrahabr.ru