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 для кэширования результатов методов.