Перейти к содержанию

JPA

JPA (Jakarta Persistence, раньше Java Persistence API) - Это стандарт Jakarta EE для ORM: набор аннотаций и интерфейсов jakarta.persistence.*, которыми описывают маппинг сущностей (POJO-классов) на таблицы БД и управляют ими через EntityManager. - JPA - только спецификация. Реализацию даёт провайдер: Hibernate, EclipseLink, OpenJPA - код на JPA переносим между провайдерами. Расширения сверх стандарта у каждого свои (см. Hibernate) - JPA 3.x - пакеты jakarta.persistence.*, JPA 2.x - старые javax.persistence.*


Подключение и persistence.xml

Зависимости - API спецификации, провайдер и JDBC-драйвер:

<dependency>
    <groupId>jakarta.persistence</groupId>
    <artifactId>jakarta.persistence-api</artifactId>
    <version>3.1.0</version>
</dependency>

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>6.0.0.Final</version>
</dependency>

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.3.1</version>
</dependency>

persistence.xml - Это конфигурация JPA: persistence unit, провайдер, подключение и настройки. Лежит в src/main/resources/META-INF/persistence.xml. - свойства jakarta.persistence.* - стандартные, понимает любой провайдер - свойства hibernate.* - только для Hibernate - transaction-type="RESOURCE_LOCAL" - транзакциями управляет приложение через EntityTransaction, JTA - контейнер (см. JTA)

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
                                 https://jakarta.ee/xml/ns/persistence/persistence_3_1.xsd"
             version="3.1">

    <persistence-unit name="myPersistenceUnit" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>

        <!-- список сущностей можно не указывать: Hibernate сам найдёт классы с @Entity -->
        <!--<class>com.example.domain.User</class>-->

        <properties>
            <!-- JDBC-соединение, стандартные свойства -->
            <property name="jakarta.persistence.jdbc.driver" value="org.postgresql.Driver"/>
            <property name="jakarta.persistence.jdbc.url" value="jdbc:postgresql://localhost:5432/mydb"/>
            <property name="jakarta.persistence.jdbc.user" value="myuser"/>
            <property name="jakarta.persistence.jdbc.password" value="mypassword"/>

            <!-- диалект: в Hibernate 6 определяется сам по соединению -->
            <property name="hibernate.dialect" value="org.hibernate.dialect.PostgreSQLDialect"/>

            <!-- генерация схемы: validate | update | create | create-drop -->
            <property name="hibernate.hbm2ddl.auto" value="update"/>

            <!-- логирование SQL -->
            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>

            <!-- кэш второго уровня, опционально -->
            <property name="hibernate.cache.use_second_level_cache" value="true"/>
            <property name="hibernate.cache.region.factory_class"
                      value="org.hibernate.cache.jcache.JCacheRegionFactory"/>
            <property name="hibernate.javax.cache.provider" value="org.ehcache.jsr107.EhcacheCachingProvider"/>
            <property name="hibernate.javax.cache.uri" value="ehcache.xml"/>

            <!-- пул соединений HikariCP, опционально: нужен модуль hibernate-hikaricp -->
            <property name="hibernate.hikari.maximumPoolSize" value="10"/>
            <property name="hibernate.hikari.minimumIdle" value="2"/>
            <property name="hibernate.hikari.idleTimeout" value="30000"/>
        </properties>
    </persistence-unit>
</persistence>

Note

В исходной версии пул подключался через com.zaxxer.hikari.hibernate.HikariConnectionProvider - это старый провайдер из самого HikariCP. В Hibernate 6 есть модуль org.hibernate.orm:hibernate-hikaricp: он включается сам, когда заданы свойства hibernate.hikari.*. Стандартная замена hibernate.hbm2ddl.auto - jakarta.persistence.schema-generation.database.action.

Без JPA native Hibernate настраивают через hibernate.cfg.xml.


Архитектура JPA

persistence.xml
    └─► Persistence Unit           - одна БД + сущности + настройки
            └─► EntityManagerFactory   - одна на приложение
                    └─► EntityManager       - один на транзакцию или запрос
                            └─► Persistence Context  - managed-сущности в памяти

Persistence Unit (PU) - Это именованная конфигурация JPA: - какая БД - какие сущности - какие настройки провайдера - имя юнита: name="myPersistenceUnit" в persistence.xml или spring.jpa.* в Spring Boot

Из Persistence Unit создаётся EntityManagerFactory, а из неё - EntityManager. Один Persistence Unit - одна БД.


EntityManagerFactory - Это фабрика EntityManager. - создаётся один раз на всё приложение при старте, из persistence.xml, и живёт весь runtime - тяжёлый объект: держит пул JDBC-соединений, метаданные и маппинги сущностей, сгенерированный SQL - соединения с БД сама не открывает, это делает EntityManager, который из неё берётся - потокобезопасна, общая для всех потоков и запросов - в Hibernate её реализует SessionFactory


Persistence Context

Persistence Context - Это набор managed-сущностей в памяти и их состояние. Внутренняя структура, через которую EntityManager хранит и отслеживает все сущности в состоянии Managed. - следит, какие объекты уже загружены из БД - проверяет, изменились ли поля (dirty checking) - знает, какие объекты нужно INSERT, UPDATE или DELETE при flush() - следит за связями (@OneToMany, @ManyToOne) и каскадами - отслеживает идентичность: два вызова em.find(Admin.class, 1L) вернут один и тот же объект, а не копию. Это и есть кэш первого уровня - по умолчанию живёт одну транзакцию (transaction-scoped). Extended-контекст живёт дольше, например в @Stateful EJB

Состояние Где находится Что делает JPA
New (transient) Вне контекста Объект ещё не сохранён
Managed Внутри persistence context Отслеживает изменения и запишет их при flush()
Detached Вышел из контекста, например после em.clear(), em.close() или конца транзакции Больше не отслеживается
Removed Помечен на удаление Будет удалён при flush()
new ──persist()──► Managed ──remove()──► Removed ──flush()──► удалён из БД
                     │  ▲
       clear(), close(), конец транзакции │  │ merge()
                     ▼  │
                   Detached

EntityManager

EntityManager - Это основной интерфейс JPA, через который приложение работает с БД: управляет жизненным циклом сущностей, выполняет CRUD, JPQL-запросы и синхронизацию с БД. - связан с одним persistence context - не потокобезопасен - в Hibernate его реализует Session

Два вида:

Тип Когда Как получить
Container-managed Jakarta EE, Spring, Quarkus @PersistenceContext - контейнер создаёт, связывает с транзакцией и закрывает
Application-managed Standalone-приложения emf.createEntityManager() - создаёшь и закрываешь сам

Container-managed. Когда контейнер (WildFly, Payara, TomEE) поднимает приложение, он читает persistence.xml, создаёт EntityManagerFactory и дальше сам выдаёт EntityManager под каждую транзакцию. После commit или rollback контекст закрывается.

@PersistenceContext(unitName = "studsPU")   // внедрить EntityManager этого Persistence Unit
private EntityManager em;

@PersistenceContext - аннотация Jakarta EE: внедрить EntityManager, связанный с Persistence Unit.

Note

В исходной версии было «контейнер создаёт новый EntityManager на каждый HTTP-запрос и внедряет его из пула» и «EntityManager связан с @RequestScoped-контекстом CDI». Внедряется прокси: при каждом вызове он находит persistence context текущей JTA-транзакции. Пула EntityManager нет, и с CDI-скоупами он не связан.

Application-managed:

EntityManagerFactory emf = Persistence.createEntityManagerFactory("studsPU");
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

Admin a = new Admin();
a.setLogin("root");
em.persist(a);

em.getTransaction().commit();
em.close();
emf.close();   // при остановке приложения

Операции с сущностями:

Метод Что делает
persist(entity) Добавить новый объект в контекст, INSERT при flush
find(EntityClass, id) Найти по первичному ключу, SELECT, если объекта ещё нет в контексте
getReference(EntityClass, id) Ленивый прокси без запроса к БД
merge(entity) Скопировать состояние detached-объекта в managed, UPDATE при flush
remove(entity) Пометить на удаление, DELETE при flush
contains(entity) Управляется ли объект контекстом
refresh(entity) Перечитать состояние из БД
flush() Синхронизировать контекст с БД: выполнить накопленные SQL
clear() Очистить контекст: все сущности становятся detached
detach(entity) Отсоединить один объект

Запросы:

Метод Что делает
createQuery(String jpql, Class<T>) JPQL-запрос: SQL по сущностям и полям, а не по таблицам
createNamedQuery(String name, Class<T>) Запрос из @NamedQuery на сущности
createNativeQuery(String sql, Class<T>) Нативный SQL с маппингом на сущность
createNativeQuery(String sql) Нативный SQL без маппинга
createQuery(CriteriaQuery<T>) Типобезопасный запрос через Criteria API

Транзакции, если управляешь сам:

Метод Что делает
getTransaction() Возвращает EntityTransaction для RESOURCE_LOCAL
joinTransaction() Присоединяет EntityManager к текущей JTA-транзакции

У Hibernate есть расширение JPQL - HQL.


Запросы в DAO и Optional

Метод DAO, который ищет одну запись, может ничего не найти. JPA сообщает об этом по-разному: - em.find() возвращает null - getSingleResult() бросает NoResultException, если строк нет, и NonUniqueResultException, если их больше одной - getResultList() возвращает пустой список

Optional (java.util.Optional, Java 8) - Это контейнер, в котором либо есть значение, либо пусто. Возвращаемый тип Optional<UserEntity> прямо говорит: пользователя с таким id или именем может не быть, и вызывающий код обязан этот случай обработать. - помогает избежать NullPointerException: отсутствие значения обрабатывается явно - код читается яснее: по сигнатуре видно, что результат может быть пустым - есть удобные методы для пустого случая: ifPresent(), orElse(), map()

public interface UserDao {
    Optional<UserEntity> getUserById(Long userId);
    Optional<UserEntity> getUserByUsername(String username);
}

public Optional<UserEntity> getUserById(Long userId) {
    UserEntity user = entityManager.find(UserEntity.class, userId);
    return Optional.ofNullable(user);   // user == null - вернётся Optional.empty()
}

public Optional<UserEntity> getUserByUsername(String username) {
    return entityManager.createQuery("select u from UserEntity u where u.username = :username", UserEntity.class)
            .setParameter("username", username)
            .getResultStream()          // пустой результат - пустой стрим, без NoResultException
            .findFirst();
}

В Spring Data findById() сразу возвращает Optional (см. Запросы в репозиториях).


Методы Optional: - isPresent() - есть ли значение. isEmpty() - наоборот, с Java 11 - ifPresent(action) - выполнить действие, если значение есть. ifPresentOrElse(action, emptyAction) - и действие на пустой случай, с Java 9 - orElse(other) - значение или значение по умолчанию. other вычисляется всегда, даже если значение есть - orElseGet(supplier) - значение или результат supplier, который вызывается только при пустом Optional - orElseThrow(exceptionSupplier) - значение или исключение. Без аргументов бросает NoSuchElementException, с Java 10 - map(mapper) - применить функцию к значению, если оно есть - flatMap(mapper) - как map, но функция сама возвращает Optional - filter(predicate) - оставить значение, если оно подходит под условие, иначе пустой Optional - or(supplier) - запасной Optional, stream() - стрим из 0 или 1 элемента, оба с Java 9

Optional<UserEntity> user = getUserById(1L);
user.ifPresent(u -> System.out.println(u.getUsername()));   // выведет имя, если пользователь есть

UserEntity u2 = getUserById(2L).orElseGet(UserEntity::new); // новый пользователь, если не найден
UserEntity u3 = getUserById(3L).orElseThrow(() -> new UserNotFoundException("User not found"));

Optional<String> username = getUserById(1L).map(UserEntity::getUsername);
Optional<String> username2 = getUserById(1L).flatMap(u -> Optional.ofNullable(u.getUsername()));
Optional<UserEntity> admin = getUserById(1L).filter(u -> u.getUsername().equals("admin"));

Note

В исходной версии был пример orElse(new UserEntity()): новый объект создаётся при каждом вызове, даже когда пользователь найден. Для значения по умолчанию, которое дорого создавать, - orElseGet(UserEntity::new). Пара if (user.isPresent()) + user.get() - то же, что проверка на null. Вместо неё ifPresent, map или orElseThrow.

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

Когда не использовать: - для полей класса - усложняет код, лучше обычное поле и проверка на null. В сущности JPA Optional-поле и не замапится - для параметров методов - Optional нужен там, где он действительно что-то сообщает, то есть в возвращаемом значении - для коллекций - пустой список вместо Optional<List<...>>

Итого: Optional в возвращаемом типе DAO явно говорит «результата может не быть» и заставляет вызывающий код обработать этот случай, а не поймать NullPointerException.


Сущность

Аннотации класса

@Entity - Это пометка класса как сущности: объекта, который сохраняется в БД. Обязательна. - конструктор без аргументов, public или protected - класс верхнего уровня, не final, не enum и не интерфейс - методы и сохраняемые поля не final - доступ к полям Hibernate выбирает по месту @Id: на поле - через поля, на геттере - через геттеры

Note

В исходной версии были требования «класс должен быть public» и «поля должны быть доступны через геттеры и сеттеры». Спецификация требует только public или protected конструктор без аргументов, а при доступе через поля геттеры не нужны.

@Table - Это описание таблицы сущности. Атрибуты: - name - имя таблицы. Не указано - берётся имя класса. Указывай всегда, если имя таблицы отличается от имени класса - schema - схема БД, когда таблицы разложены по нескольким схемам - uniqueConstraints - уникальные ограничения для бизнес-правил - indexes - индексы: name - имя индекса, columnList - колонки через запятую, если индекс составной. Ставят на часто используемые в поиске поля - uniqueConstraints и indexes в runtime ничего не делают: это инструкция для генерации схемы. Если схему создают миграции, они только документируют её

@Table(
    name = "users",
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_user_email", columnNames = "email"),   // email и phone уникальны
        @UniqueConstraint(name = "uk_user_phone", columnNames = "phone")
    },
    indexes = @Index(name = "idx_user_last_name", columnList = "last_name")
)

Уникальность одной колонки проще задать прямо на поле - @Column(unique = true). Дублировать её ещё и в @Table(uniqueConstraints) не нужно:

// избыточно: одно и то же ограничение дважды
@Table(uniqueConstraints = @UniqueConstraint(columnNames = "email"))
public class User {
    @Column(unique = true)
    private String email;
}

// лучше
public class User {
    @Column(unique = true)
    private String email;
}

Остальные аннотации класса:

Аннотация Что делает
@SecondaryTable Маппит сущность на несколько таблиц. Поле в другой таблице - @Column(table = "...")
@Inheritance(strategy = ...) Стратегия наследования: SINGLE_TABLE (по умолчанию), JOINED, TABLE_PER_CLASS
@DiscriminatorColumn(name = "type") Колонка с типом сущности для SINGLE_TABLE
@DiscriminatorValue("value") Значение дискриминатора для текущего класса
@MappedSuperclass Класс-родитель, его поля маппятся в таблицы наследников. Сам не сущность
@Embeddable Встраиваемый value-объект, не сущность. В сущность подключается через @Embedded
@Cacheable Сущность кэшируется во втором уровне (см. Кэширование)
@EntityListeners Классы-слушатели событий жизненного цикла, например аудит
@NamedQuery, @NamedEntityGraph Именованный JPQL-запрос и план загрузки рядом с сущностью

Первичный ключ

@Id - Это первичный ключ. Единственная обязательная аннотация поля в любой сущности.

@GeneratedValue - Это стратегия генерации значения ключа.

Стратегия Как работает Плюсы Минусы
AUTO Провайдер выбирает сам под эту БД. На PostgreSQL Hibernate выберет SEQUENCE Ничего настраивать Неочевидно, что выбрано
IDENTITY id генерирует колонка БД при INSERT Простейшая настройка, идеально для MySQL. Всё делает база id известен только после INSERT, batch insert не работает
SEQUENCE id берётся из sequence БД, можно пачкой заранее id известен до INSERT, работает batch insert, гибкая оптимизация (pooled, hi/lo). Рекомендуемый способ для PostgreSQL Нужна поддержка sequence в БД, аккуратная настройка allocationSize
TABLE Sequence симулируется обычной таблицей Работает на любой SQL-БД Медленно, блокировка на каждый блок id, узкое место по конкурентности. Сейчас почти не нужен

IDENTITY - id генерируется только при реальном INSERT, провайдер его заранее не знает. - при INSERT Hibernate не передаёт id, колонка генерирует значение сама - после вставки узнаёт id через JDBC getGeneratedKeys() или SELECT LAST_INSERT_ID() и SELECT @@IDENTITY - Hibernate не может нормально делать batch insert: id нет заранее. И вставку нельзя отложить без похода в БД

SEQUENCE - отдельный объект БД выдаёт числа: nextval('user_seq') → 1, 2, 3. Провайдер может заранее запросить пачку id и раздать их объектам ещё до INSERT. - allocationSize = 50 - Hibernate один раз берёт значение из sequence и сам выдаёт 50 id в памяти. Обращений к sequence в 50 раз меньше - шаг sequence в БД должен совпадать с allocationSize, иначе будут дыры или конфликты id

CREATE SEQUENCE user_seq START 1 INCREMENT 50;   -- шаг = allocationSize
CREATE TABLE users
(
    id   BIGINT PRIMARY KEY,
    name TEXT
);
@Entity
@SequenceGenerator(name = "user_seq_gen", sequenceName = "user_seq", allocationSize = 50)
class User {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq_gen")
    private Long id;
}

Warning

В исходном примере было INCREMENT 1 при allocationSize = 50. Hibernate 6 по умолчанию проверяет шаг sequence при старте и с таким расхождением падает. А без проверки два экземпляра приложения выдали бы пересекающиеся id.

TABLE - таблица id_generator играет роль sequence. Перед вставкой Hibernate: 1) Делает SELECT next_val FROM id_generator WHERE id_name = 'user' FOR UPDATE. 2) Увеличивает next_val. 3) Использует старое значение как id или как начало блока с учётом allocationSize.

CREATE TABLE id_generator
(
    id_name  VARCHAR(50) PRIMARY KEY,
    next_val BIGINT
);
INSERT INTO id_generator (id_name, next_val) VALUES ('user', 1);
@Entity
@TableGenerator(name = "user_table_gen", table = "id_generator", pkColumnName = "id_name",
        valueColumnName = "next_val", pkColumnValue = "user", allocationSize = 50)
class User {
    @Id
    @GeneratedValue(strategy = GenerationType.TABLE, generator = "user_table_gen")
    private Long id;
}

Аннотации полей

@Column - Это основная аннотация маппинга поля на столбец.

Атрибут Тип Что задаёт
name String Имя столбца, если отличается от имени поля
nullable boolean Может ли быть NULL, по умолчанию true
unique boolean Уникальность, как UNIQUE в SQL
length int Максимальная длина строки, по умолчанию 255 - VARCHAR(255)
precision, scale int Всего цифр и цифр после запятой для DECIMAL
insertable, updatable boolean Участвует ли поле в INSERT и UPDATE, по умолчанию true
columnDefinition String Точный SQL-тип столбца
table String Таблица из @SecondaryTable, где лежит столбец

Другие аннотации полей: - @Basic - обычное поле: примитив, строка, обёртка. fetch - EAGER по умолчанию или LAZY для больших типов. optional - то же, что nullable, для примитивов всегда false - @Lob - поле CLOB или BLOB: String, Clob, byte[], Blob - @Temporal - для старых java.util.Date и Calendar: DATE, TIME, TIMESTAMP. Для java.time (LocalDate, Instant) не нужна - @Enumerated - enum-поле. По умолчанию ORDINAL - номер константы, лучше EnumType.STRING: порядок констант можно менять, не ломая данные - @Transient - поле не сохраняется в БД - @Version - версия для оптимистичной блокировки (см. Optimistic locking) - @Embedded - поле-компонент из @Embeddable, его колонки лежат в таблице сущности


Колбэки жизненного цикла

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

Аннотация Когда вызывается
@PrePersist Перед INSERT: сущность переходит из new в managed и готовится к вставке
@PostPersist Сразу после INSERT
@PreUpdate Перед UPDATE
@PostUpdate После успешного UPDATE
@PreRemove Перед DELETE, когда сущность помечают на удаление
@PostRemove После DELETE
@PostLoad Каждый раз после загрузки сущности из БД

Колбэки можно вынести в отдельный класс и подключить через @EntityListeners. Так устроен аудит в Spring Data: @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy (см. Аудит сущностей).


Связи

Владелец связи - сторона, в таблице которой лежит внешний ключ (FK). На ней стоит @JoinColumn, и только её изменения пишутся в БД. Обратная сторона (inverse) ссылается на владельца через mappedBy.

parent(id PK, ...)
child(id PK, parent_id FK -> parent.id NOT NULL, ...)
INDEX(child.parent_id)   -- индекс на FK нужен
Связь Где FK fetch по умолчанию
@ManyToOne У этой стороны, она владелец EAGER
@OneToOne У стороны с @JoinColumn EAGER
@OneToMany У другой стороны (@ManyToOne) LAZY
@ManyToMany В join-таблице LAZY

Warning

@ManyToOne и @OneToOne по умолчанию EAGER. В исходной версии было «LAZY по умолчанию для @ManyToOne» - это неверно, поэтому fetch = FetchType.LAZY на них ставят явно.


@JoinColumn

@JoinColumn - Это явное описание столбца с внешним ключом. Ставится на стороне-владельце: с @ManyToOne, @OneToOne или на однонаправленной @OneToMany.

Атрибут По умолчанию Что задаёт
name <имя_поля>_<pk_цели>, например customer_id Имя FK-колонки в таблице владельца
referencedColumnName PK целевой сущности Столбец целевой таблицы, на который ссылается FK. Нужен, если ссылаются не на id, а например на uuid
nullable true false - NOT NULL. Для обязательной связи вместе с optional = false
unique false UNIQUE на FK, например чтобы @OneToOne был один к одному и в БД
insertable, updatable true Включается ли колонка в INSERT и UPDATE. false - если её заполняет триггер или она дублирует другое поле. Нужно редко
columnDefinition "" Сырой SQL-фрагмент столбца: тип, default
table "" Таблица из @SecondaryTable, где лежит столбец
foreignKey Имя выбирает провайдер @ForeignKey(name = "...") - имя ограничения, ConstraintMode.NO_CONSTRAINT - не создавать FK. С JPA 2.1
@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)   // заказ всегда принадлежит покупателю
    @JoinColumn(
        name = "customer_id",                              // колонка в таблице orders
        referencedColumnName = "id",                       // ссылается на customers.id
        nullable = false,                                  // NOT NULL
        foreignKey = @ForeignKey(name = "fk_order_customer")
    )
    private Customer customer;
}

Note

В исходной версии @ForeignKey описан как способ задать ON DELETE и ON UPDATE. В JPA у @ForeignKey есть только имя, режим и foreignKeyDefinition - сырой SQL всего ограничения. Отдельных атрибутов для ON DELETE нет.


@ManyToOne и @OneToMany

@ManyToOne - Это ссылка «много к одному». Сторона с ней - владелец, FK в её таблице. - fetch - ставь LAZY явно - optional = false - связь обязательна, синхронизируй с @JoinColumn(nullable = false) - cascade - на @ManyToOne обычно не ставят: удалять родителя вслед за ребёнком почти никогда не нужно - targetEntity - класс цели, если тип поля - дженерик или интерфейс

@OneToMany - Это коллекция «один ко многим» на обратной стороне. - mappedBy = "parent" - поле в дочерней сущности, которое владеет связью - cascade - какие операции пробрасывать на детей - orphanRemoval = true - удалять ребёнка из БД, когда его убрали из коллекции - fetch - LAZY по умолчанию - коллекцию всегда инициализируют: new ArrayList<>()

Рекомендованная модель - двусторонняя связь: Child (владелец, FK) ↔ Parent (обратная, mappedBy):

@Entity
class Parent {
    @Id @GeneratedValue Long id;

    @OneToMany(
        mappedBy = "parent",          // FK в Child
        cascade = CascadeType.ALL,    // сохраняем и обновляем детей через Parent
        orphanRemoval = true,         // убрали из коллекции - удалили из БД
        fetch = FetchType.LAZY
    )
    private List<Child> children = new ArrayList<>();

    // хелперы всегда синхронизируют обе стороны
    public void addChild(Child c) {
        children.add(c);
        c.setParent(this);
    }

    public void removeChild(Child c) {
        children.remove(c);
        c.setParent(null);
    }
}

@Entity
class Child {
    @Id @GeneratedValue Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "parent_id", nullable = false, foreignKey = @ForeignKey(name = "fk_child_parent"))
    private Parent parent;
}
  • нет лишней join-таблицы, схема простая и быстрая
  • orphanRemoval + хелперы = безопасное удаление детей из БД при удалении из коллекции
  • LAZY убирает «жирные» запросы
  • однонаправленный @OneToMany без mappedBy и без @JoinColumn создаёт лишнюю join-таблицу. Обычно это не нужно

Note

В исходной версии было «всегда используйте mappedBy на стороне владельца». mappedBy ставят на обратной стороне - той, у которой FK нет. А @JoinColumn на однонаправленном @OneToMany - стандарт JPA 2.0, а не возможность только Hibernate.


@OneToOne

@OneToOne - Это связь «один к одному». Владелец - сторона с @JoinColumn, обратная - с mappedBy. - fetch - EAGER по умолчанию, ставь LAZY - optional = false - связь обязательна: NOT NULL для FK. Синхронизируй с @JoinColumn(nullable = false) - cascade - PERSIST, MERGE, REMOVE, REFRESH, DETACH, ALL: пробросить операции на зависимую сущность - orphanRemoval = true - при снятии связи (присвоили null) зависимая сущность удалится. Ставят, когда её жизнь полностью подчинена родителю - @JoinColumn(unique = true) - один к одному и на уровне БД

@Entity
class Order {
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "invoice_id", unique = true, nullable = false)   // владелец: FK здесь
    private Invoice invoice;
}

@Entity
class Invoice {
    @OneToOne(mappedBy = "invoice", fetch = FetchType.LAZY)   // обратная сторона
    private Order order;
}

Note

На обратной стороне @OneToOne провайдер не знает без запроса, есть ли связанный объект, поэтому LAZY там часто не срабатывает. В Hibernate это обходят bytecode enhancement (см. Загрузка связей).


@ManyToMany

@ManyToMany - Это связь «многие ко многим» через join-таблицу с двумя FK. Нужна редко: когда у связи нет своих полей. Одна сторона - владелец (с @JoinTable, без mappedBy), другая - обратная (mappedBy).

a(id PK, ...)
b(id PK, ...)
a_b(
    a_id FK -> a.id NOT NULL,
    b_id FK -> b.id NOT NULL,
    PRIMARY KEY (a_id, b_id)
)
CREATE INDEX idx_a_b__a ON a_b(a_id);
CREATE INDEX idx_a_b__b ON a_b(b_id);
@Entity
class Student {
    @Id @GeneratedValue Long id;

    @ManyToMany   // владелец: пишет в student_course
    @JoinTable(
        name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id"),
        foreignKey = @ForeignKey(name = "fk_student_course_student"),
        inverseForeignKey = @ForeignKey(name = "fk_student_course_course")
    )
    private Set<Course> courses = new HashSet<>();   // Set, а не List

    // хелперы держат обе стороны в синхроне
    public void addCourse(Course c) {
        if (courses.add(c)) c.getStudents().add(this);
    }

    public void removeCourse(Course c) {
        if (courses.remove(c)) c.getStudents().remove(this);
    }
}

@Entity
class Course {
    @Id @GeneratedValue Long id;

    @ManyToMany(mappedBy = "courses")   // обратная сторона
    private Set<Student> students = new HashSet<>();

    public Set<Student> getStudents() { return students; }
}
  • Set вместо List - защита от дубликатов в коллекции. Уникальность пары всё равно держат в БД
  • mappedBy ровно на одной стороне. Без него на обеих сторонах получится две join-таблицы
  • cascade осторожно: CascadeType.ALL или REMOVE на @ManyToMany удалит связанные сущности, а не только связь
  • обратная навигация не нужна - делают одностороннюю связь: @ManyToMany + @JoinTable только у владельца. Кода и синхронизации меньше, уникальность и индексы в БД всё равно нужны

Связь со своими полями (роль, статус, позиция, даты, комментарий) - это уже не @ManyToMany, а link-сущность:

@Entity
class AB {
    @EmbeddedId
    private ABId id;

    @ManyToOne(fetch = FetchType.LAZY) @MapsId("aId")
    @JoinColumn(name = "a_id")
    private A a;

    @ManyToOne(fetch = FetchType.LAZY) @MapsId("bId")
    @JoinColumn(name = "b_id")
    private B b;

    // поля самой связи
    private String role;
    private Integer position;
    private LocalDate since;
}

@Embeddable
class ABId implements Serializable {
    private Long aId;
    private Long bId;
    // equals и hashCode по обоим полям
}

Можно хранить свои атрибуты, уникальные ключи, порядок, статусы. Цена - ещё один класс, но это правильная модель.


Каскады и orphanRemoval

cascade - Это операции, которые применяются к связанным сущностям вслед за родителем.

Тип Что пробрасывает
PERSIST Сохранение
MERGE Слияние detached-состояния
REMOVE Удаление
REFRESH Перечитывание из БД
DETACH Отсоединение от контекста
ALL Всё перечисленное
// сохранение, обновление и удаление пользователя пробрасываются на его заказы
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE})
private List<Order> orders;
  • PERSIST и MERGE - часто на границах агрегата: родитель → дети
  • REMOVE - только если ребёнку нельзя жить без родителя и нужно физическое удаление
  • @OneToMany - часто CascadeType.ALL + orphanRemoval
  • @ManyToOne - каскады обычно не ставят
  • @ManyToMany - осторожно: ALL может случайно удалить связанные сущности

orphanRemoval = true - запись удалится при parent.getChildren().remove(x) или child.setParent(null). Без него ребёнок, убранный из коллекции, остаётся в БД «висячей» записью.


Чек-лист связи: - где физически FK? - там владелец и @JoinColumn - нужна обратная навигация? - mappedBy на обратной стороне - связь обязательна? - optional = false + nullable = false - нужен каскад? - минимум, только то, что реально нужно - ожидаются пачки чтения? - LAZY + EntityGraph или JOIN FETCH - EAGER не ставь: тяжёлые запросы и неожиданные циклические загрузки - FK называй явно через @ForeignKey, иначе Hibernate придумает неочевидное имя

Частые ошибки - лишняя join-таблица, рассинхрон сторон, потеря детей при замене коллекции, сломанные equals и hashCode, каскады «сносят полмира».


N+1 и планы загрузки

N+1 - Это лишние запросы при загрузке связей. Приложение делает 1 запрос за родительскими сущностями, а потом по 1 отдельному запросу на каждого родителя, чтобы подтянуть его детей (с WHERE по родителю). 100 родителей - 101 запрос.

Решение 1. LAZY. Помогает, только если дети не нужны. Если к ним обратиться, те же N запросов просто выполнятся позже.

Решение 2. JOIN FETCH. Запрос с JOIN, который сразу загружает детей - один SQL вместо N+1.

@Repository
public class ClientRepository {

    @PersistenceContext
    private EntityManager entityManager;

    public List<Client> findAllWithPayments() {
        return entityManager.createQuery("select c from Client c join fetch c.payments", Client.class)
                .getResultList();
    }
}

В Spring Data тот же JPQL ставят в @Query на методе репозитория, а план загрузки - в @EntityGraph (см. Запросы в репозиториях).

  • JOIN FETCH по коллекции размножает строки родителя. Hibernate 6 убирает дубли сам, в Hibernate 5 нужен select distinct
  • с пагинацией JOIN FETCH по коллекции не дружит: Hibernate выберет всё и разобьёт на страницы в памяти (см. Коллекции на списочных экранах)

Note

В исходных примерах был @ALLArgsConstructor с final EntityManager под @PersistenceContext и не хватало скобок. @PersistenceContext внедряется в поле, а не через конструктор, поэтому поле не может быть final.


Решение 3. Entity Graph. EntityGraph - Это разовый план загрузки: какие связи сущности подгрузить прямо сейчас. - аннотации LAZY и EAGER не меняет, действует только на тот find или запрос, куда его передали - работает с em.find(...) и с JPQL и Criteria-запросами SELECT ... FROM <RootEntity> - @NamedEntityGraph - именованный граф рядом с сущностью, доступен по имени - em.createEntityGraph(Vehicle.class) - программный граф, собирается в runtime

@NamedEntityGraph(
    name = "Vehicle.withCoordinatesAdmin",
    attributeNodes = {
        @NamedAttributeNode("coordinates"),
        @NamedAttributeNode("admin")
    }
)
@Entity
class Vehicle { }

// или программно
EntityGraph<Vehicle> g = em.createEntityGraph(Vehicle.class);
g.addAttributeNodes("coordinates", "admin");

Как применить:

// к em.find
EntityGraph<?> g = em.getEntityGraph("Vehicle.withCoordinatesAdmin");
Vehicle v = em.find(Vehicle.class, id, Map.of("jakarta.persistence.loadgraph", g));

// к JPQL или Criteria
List<Vehicle> list = em.createQuery("select v from Vehicle v where v.id in :ids", Vehicle.class)
        .setParameter("ids", ids)
        .setHint("jakarta.persistence.loadgraph", g)
        .getResultList();

Два режима: - loadgraph - атрибуты из графа грузятся сразу, остальные - как в маппинге (LAZY или EAGER) - fetchgraph - сразу грузятся только атрибуты из графа, все прочие считаются LAZY, даже если помечены EAGER. Строгий список полей

Вложенность - subgraph. Граф по умолчанию действует на один уровень: Vehicle.admin. Глубже - через подграф:

@NamedEntityGraph(
    name = "Vehicle.withAdminAndOrg",
    attributeNodes = {
        @NamedAttributeNode(value = "admin", subgraph = "admin.sg"),
        @NamedAttributeNode("coordinates")
    },
    subgraphs = @NamedSubgraph(
        name = "admin.sg",
        attributeNodes = @NamedAttributeNode("organization")   // связь внутри Admin
    )
)

Note

Коллекции @OneToMany с mappedBy в NamedEntityGraph лучше не класть. Две коллекции в одном графе дают декартово произведение строк, а в Hibernate - MultipleBagFetchException для двух List.

Решение 4. Batch fetching - подгрузка ленивых связей пачками через IN (...). Это возможность Hibernate, а не JPA: @BatchSize и @Fetch(FetchMode.SUBSELECT).

Решение Запросов Когда
LAZY 1 + N при обращении Дети не нужны
JOIN FETCH 1 Дети нужны всегда для этого запроса
EntityGraph 1 Один и тот же JPQL, но разные планы загрузки
@BatchSize (Hibernate) 1 + N / размер пачки Компромисс между JOIN и ленивой загрузкой

Criteria API

Criteria API - Это типобезопасная альтернатива JPQL: запрос строится не строкой, а Java-кодом в runtime через CriteriaBuilder, CriteriaQuery, Root.

Проблема JPQL Как решает Criteria
Строки: ошибки видны только в runtime Компилятор проверяет типы (с метамоделью - и имена полей)
Сложно строить динамические фильтры: если параметр есть - добавить условие Условия добавляются программно, хоть в цикле
Переименование поля ломает запрос С метамоделью IDE-рефакторинг обновит запрос

Основные участники:

Класс Назначение
CriteriaBuilder Точка входа: создаёт запросы и выражения equal, like, and, or, sum
CriteriaQuery<T> Сам запрос: select, from, where, orderBy, groupBy
Root<T> Корень выборки - то, что стоит во FROM. Создаётся через CriteriaQuery.from(Entity.class), частный случай From<T, T>
From<Z, X> Источник строк: таблица или присоединённая таблица. Умеет делать JOIN и хранит уже сделанные
Join<X, Y> JOIN к связанной сущности: root.join("admin")
Path<X> Путь к атрибуту: столбцу, embedded-полю, join'у. Из путей строят предикаты: cb.equal(path, ...)
Predicate Условие: результат cb.equal(), cb.greaterThan(), cb.and()
TypedQuery<T> Выполняет запрос: em.createQuery(criteriaQuery)
EntityManager
   └─► getCriteriaBuilder() ─► CriteriaBuilder ─► CriteriaQuery<Vehicle>
                                                    ├── FROM   → Root<Vehicle> (v)
                                                    │            ├── Path: v.enginePower
                                                    │            ├── Join admin (a) ─► Path: a.id
                                                    │            └── Join coordinates (c) ─► Path: c.x, c.y
                                                    ├── WHERE  → Predicate
                                                    ├── SELECT → проекция
                                                    └── ORDER BY / GROUP BY
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Vehicle> cq = cb.createQuery(Vehicle.class);
Root<Vehicle> v = cq.from(Vehicle.class);
Join<Vehicle, Admin> a = v.join("admin");

List<Predicate> where = new ArrayList<>();
if (type != null) {
    where.add(cb.equal(v.get("type"), type));        // условие добавляется, только если фильтр задан
}
where.add(cb.equal(a.get("id"), adminId));

cq.select(v)
  .where(where.toArray(Predicate[]::new))
  .orderBy(cb.desc(v.get("creationTime")));

List<Vehicle> result = em.createQuery(cq).getResultList();

Bean Validation и ограничения

Jakarta Bean Validation - Это отдельная спецификация валидационных аннотаций. Их ставят на сущности, DTO и form-объекты. - JPA сама проверяет сущность перед INSERT и UPDATE, если в classpath есть реализация (Hibernate Validator) - в REST-контроллере валидацию запускает @Valid: ошибка - ответ 400 до вызова метода (см. Валидация)

Аннотация Проверяет
@NotNull Не null
@NotBlank Строка не null и не пустая после обрезки пробелов
@NotEmpty Строка, коллекция или массив не null и не пустые
@Size(min, max) Длина строки или размер коллекции и массива
@Pattern(regexp = "^[A-Za-z0-9]+$") Соответствие регулярному выражению
@Min(18), @Max(100) Границы числа
@Positive, @PositiveOrZero Число > 0 и ≥ 0
@Negative, @NegativeOrZero Число < 0 и ≤ 0
@Digits(integer = 3, fraction = 2) Количество цифр в целой и дробной части
@Past, @PastOrPresent Дата в прошлом, в прошлом или сейчас
@Future, @FutureOrPresent Дата в будущем, в будущем или сейчас
@Email Валидный email
@URL Валидный URL. Из Hibernate Validator
@CreditCardNumber Номер карты по алгоритму Луна. Из Hibernate Validator
@Length(min, max) Аналог @Size для строк. Из Hibernate Validator
@Column(nullable = false)   // ограничение в БД
@NotNull                    // проверка в Java до INSERT
@Size(min = 3, max = 50)
private String username;

Где задавать ограничения: в JPA-модели или в PostgreSQL:

Способ Плюсы Минусы Когда
Только в JPA Простота, переносимость между БД Нет гарантии на уровне БД Простые проекты, прототипы
Только в PostgreSQL Максимальная надёжность Сложно поддерживать миграции Системы, критичные к данным
Комбинированный Баланс надёжности и гибкости Дублирование кода Большинство production-проектов

Практики для сущностей

  • DTO для передачи данных, а не сущности напрямую
  • Неизменяемость - не давай сеттеры там, где поле не должно меняться. Поля сущности при этом не final: JPA это запрещает. Read-only сущность целиком - @Immutable в Hibernate
  • Fetch-стратегии - LAZY везде, где не нужен EAGER. Связи под конкретный запрос - JOIN FETCH или EntityGraph (см. N+1)
  • Проекции - не тащи сущность целиком, если нужны три поля: DTO-проекция в JPQL (select new com.example.UserDto(u.id, u.name) from User u) или @NamedEntityGraph
  • Массовые операции - один UPDATE или DELETE в JPQL через executeUpdate(), а не цикл по сущностям. Они идут мимо persistence context: после них контекст стоит очистить
  • Валидация - Bean Validation на DTO и сущностях
  • Кэширование - @Cacheable для часто читаемых и редко меняемых сущностей
  • Аудит - @CreatedDate, @LastModifiedDate (см. Аудит сущностей)
  • Конкурентные обновления - @Version для оптимистичной блокировки

В Spring Data массовые операции - @Modifying + @Query (см. Запросы в репозиториях).

Note

В исходной версии было «делай сущности неизменяемыми через final поля» и «@Cacheable (Spring) + L2 Cache». JPA запрещает final у сохраняемых полей. А @Cacheable на сущности - это jakarta.persistence.Cacheable, а не Spring-аннотация @Cacheable для кэширования результатов методов.