«1. Обзор

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

В таких случаях может оказаться желательным извлекать данные как объекты настраиваемых типов. Эти типы отражают частичные представления корневого класса, содержащие только интересующие нас свойства. Вот где проекции пригодятся.

2. Первоначальная настройка

Первым шагом является настройка проекта и заполнение базы данных.

2.1. Зависимости Maven

Чтобы узнать о зависимостях, ознакомьтесь с разделом 2 этого руководства.

2.2. Классы сущностей

Давайте определим два класса сущностей:

@Entity
public class Address {
 
    @Id
    private Long id;
 
    @OneToOne
    private Person person;
 
    private String state;
 
    private String city;
 
    private String street;
 
    private String zipCode;

    // getters and setters
}

И:

@Entity
public class Person {
 
    @Id
    private Long id;
 
    private String firstName;
 
    private String lastName;
 
    @OneToOne(mappedBy = "person")
    private Address address;

    // getters and setters
}

Отношения между сущностями Person и Address являются двунаправленными один к одному: Address является стороной-владельцем, а Person является обратная сторона.

Обратите внимание, что в этом руководстве мы используем встроенную базу данных — H2.

Когда встроенная база данных настроена, Spring Boot автоматически создает базовые таблицы для определенных нами сущностей.

2.3. Сценарии SQL

Мы используем сценарий projection-insert-data.sql для заполнения обеих резервных таблиц:

INSERT INTO person(id,first_name,last_name) VALUES (1,'John','Doe');
INSERT INTO address(id,person_id,state,city,street,zip_code) 
  VALUES (1,1,'CA', 'Los Angeles', 'Standford Ave', '90001');

Чтобы очищать базу данных после каждого запуска теста, мы можем использовать другой сценарий с именем projection-clean- up-data.sql:

DELETE FROM address;
DELETE FROM person;

2.4. Тестовый класс

Для подтверждения того, что проекции дают правильные данные, нам нужен тестовый класс:

@DataJpaTest
@RunWith(SpringRunner.class)
@Sql(scripts = "/projection-insert-data.sql")
@Sql(scripts = "/projection-clean-up-data.sql", executionPhase = AFTER_TEST_METHOD)
public class JpaProjectionIntegrationTest {
    // injected fields and test methods
}

С заданными аннотациями Spring Boot создает базу данных, внедряет зависимости, а также заполняет и очищает таблицы до и после каждого теста. выполнение метода.

3. Проекции на основе интерфейса

При проектировании объекта естественно полагаться на интерфейс, поскольку нам не нужно предоставлять реализацию.

3.1. Закрытые проекции

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

Давайте объявим интерфейс проекции для класса Address:

public interface AddressView {
    String getZipCode();
}

Затем используем его в интерфейсе репозитория:

public interface AddressRepository extends Repository<Address, Long> {
    List<AddressView> getAddressByState(String state);
}

Легко видеть, что определение метода репозитория с интерфейсом проекции в значительной степени является так же, как с классом сущности.

Единственное отличие состоит в том, что интерфейс проекции, а не класс сущности, используется в качестве типа элемента в возвращаемой коллекции.

Давайте быстро протестируем проекцию Address:

@Autowired
private AddressRepository addressRepository;

@Test
public void whenUsingClosedProjections_thenViewWithRequiredPropertiesIsReturned() {
    AddressView addressView = addressRepository.getAddressByState("CA").get(0);
    assertThat(addressView.getZipCode()).isEqualTo("90001");
    // ...
}

За кулисами Spring создает прокси-экземпляр интерфейса проекции для каждого объекта сущности, и все вызовы прокси перенаправляются на этот объект.

Мы можем использовать проекции рекурсивно. Например, вот интерфейс проекции для класса Person:

public interface PersonView {
    String getFirstName();

    String getLastName();
}

Теперь давайте добавим метод с возвращаемым типом PersonView — вложенную проекцию — в проекцию Address:

public interface AddressView {
    // ...
    PersonView getPerson();
}

Уведомление имя метода, возвращающего вложенную проекцию, должно совпадать с именем метода в корневом классе, который возвращает связанный объект.

Давайте проверим вложенные проекции, добавив несколько операторов в только что написанный метод тестирования:

// ...
PersonView personView = addressView.getPerson();
assertThat(personView.getFirstName()).isEqualTo("John");
assertThat(personView.getLastName()).isEqualTo("Doe");

Обратите внимание, что рекурсивные проекции работают, только если мы переходим от стороны-владельца к обратной стороне. Если бы мы сделали это наоборот, для вложенной проекции было бы установлено значение null.

3.2. Открытые проекции

До этого момента мы рассматривали закрытые проекции, которые обозначают проекционные интерфейсы, чьи методы точно соответствуют именам свойств сущности.

Есть еще один вид проекций, основанных на интерфейсе: открытые проекции. Эти проекции позволяют нам определять методы интерфейса с непревзойденными именами и возвращаемыми значениями, вычисляемыми во время выполнения.

Вернемся к интерфейсу проекции Person и добавим новый метод:

public interface PersonView {
    // ...

    @Value("#{target.firstName + ' ' + target.lastName}")
    String getFullName();
}

Аргументом аннотации @Value является выражение SpEL, в котором целевое обозначение указывает на поддерживающий объект сущности.

Теперь мы определим другой интерфейс репозитория:

public interface PersonRepository extends Repository<Person, Long> {
    PersonView findByLastName(String lastName);
}

Для простоты мы возвращаем только один объект проекции вместо коллекции.

«Этот тест подтверждает, что открытые проекции работают должным образом:

@Autowired
private PersonRepository personRepository;

@Testpublic void whenUsingOpenProjections_thenViewWithRequiredPropertiesIsReturned() {
    PersonView personView = personRepository.findByLastName("Doe");
 
    assertThat(personView.getFullName()).isEqualTo("John Doe");
}

У открытых проекций есть недостаток: Spring Data не может оптимизировать выполнение запросов, поскольку заранее не знает, какие свойства будут использоваться. Таким образом, мы должны использовать открытые проекции только тогда, когда закрытые проекции не могут удовлетворить наши требования.

4. Проекции на основе классов

Вместо использования прокси-серверов, которые Spring Data создает для нас из интерфейсов проекций, мы можем определить наши собственные классы проекций.

Например, вот класс проекции для объекта Person:

public class PersonDto {
    private String firstName;
    private String lastName;

    public PersonDto(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    // getters, equals and hashCode
}

Чтобы класс проекции работал в тандеме с интерфейсом репозитория, имена параметров его конструктора должны соответствовать свойствам класса корневого объекта.

Мы также должны определить реализации equals и hashCode — они позволяют Spring Data обрабатывать проекционные объекты в коллекции.

Теперь давайте добавим метод в репозиторий Person:

public interface PersonRepository extends Repository<Person, Long> {
    // ...

    PersonDto findByFirstName(String firstName);
}

Этот тест проверяет нашу проекцию на основе классов:

@Test
public void whenUsingClassBasedProjections_thenDtoWithRequiredPropertiesIsReturned() {
    PersonDto personDto = personRepository.findByFirstName("John");
 
    assertThat(personDto.getFirstName()).isEqualTo("John");
    assertThat(personDto.getLastName()).isEqualTo("Doe");
}

Обратите внимание, что при подходе на основе классов мы не можем использовать вложенные проекции.

5. Динамические проекции

Класс сущностей может иметь много проекций. В некоторых случаях мы можем использовать определенный тип, но в других случаях нам может понадобиться другой тип. Иногда нам также нужно использовать сам класс сущности.

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

Мы можем применить динамические проекции, просто объявив метод репозитория с параметром класса:

public interface PersonRepository extends Repository<Person, Long> {
    // ...

    <T> T findByLastName(String lastName, Class<T> type);
}

Передав в такой метод тип проекции или класс сущности, мы можем получить объект нужного типа: ~ ~~

@Test
public void whenUsingDynamicProjections_thenObjectWithRequiredPropertiesIsReturned() {
    Person person = personRepository.findByLastName("Doe", Person.class);
    PersonView personView = personRepository.findByLastName("Doe", PersonView.class);
    PersonDto personDto = personRepository.findByLastName("Doe", PersonDto.class);

    assertThat(person.getFirstName()).isEqualTo("John");
    assertThat(personView.getFirstName()).isEqualTo("John");
    assertThat(personDto.getFirstName()).isEqualTo("John");
}

6. Заключение

В этой статье мы рассмотрели различные типы проекций Spring Data JPA.

Исходный код этого руководства доступен на GitHub. Это проект Maven, и он должен работать как есть.