1. Что собираем в практике
Если до этого момента JPA могла казаться набором аннотаций, то сегодня мы наконец увидим «замкнутый цикл»: Java-объект → JPA → SQL → PostgreSQL → обратно в Java-объект. Это такой первый «Hello, world!» в новой технологии, только вместо «Hello» у нас вполне взрослая вещь: запись в таблицу и чтение из неё. И да, SQL-лог здесь наш рентген: он показывает, что реально происходит.
В этой лекции мы делаем простую, но принципиальную вещь: создаём две сущности каталога (Category и Product) без связей между ними. Связи — отдельная тема; сейчас важнее научиться читать базовый маппинг. Затем через EntityManager прогоним два сценария: сохранить объект и загрузить его по id. После этого откроем SQL, который реально улетел в базу, и сопоставим его с тем, что написали в коде.
Чтобы держать картинку в голове, вот мини-схема того, что будет происходить:
flowchart TD
A[Java объект Category/Product] --> B[EntityManager.persist]
B --> C[Generated SQL: INSERT + nextval]
C --> D["PostgreSQL"]
D --> E[Generated SQL: SELECT ... WHERE id = ?]
E --> F[EntityManager.find]
F --> G[Java объект снова в руках]
Главная цель не в том, чтобы «просто заработало». Главная цель — чтобы вы умели в любой момент открыть лог и сказать себе: «Окей, вот этот INSERT — результат моего @Column, а вот этот SELECT — это мой find».
2. Настройка dev и SQL-лог
Перед тем как писать код, нужно договориться о скучной, но жизненно важной вещи: как именно мы увидим генерируемый SQL. Если SQL не виден, JPA снова превращается в магию. Если SQL виден, вы контролируете ситуацию. Это примерно как готовить без возможности попробовать еду: формально можно, но о том, что суп «немного пересолен», вы узнаете только по лицам гостей.
Мы исходим из того, что после Дня 4 у вас уже поднят PostgreSQL через Docker Compose и Spring Boot приложение уверенно подключается к базе. Сейчас нам нужны два режима: в dev мы позволяем Hibernate быстро создать таблицы — чисто ради учебной скорости — и включаем логирование SQL. Это не «финальная дисциплина схемы», а именно учебный режим: нам важно увидеть INSERT/SELECT и связать их с аннотациями.
Пример минимальных настроек для src/main/resources/application-dev.yml:
spring:
jpa:
hibernate:
# Учебный режим: быстро поднимаем схему при старте и очищаем при остановке
ddl-auto: create-drop
properties:
hibernate:
# Чтобы SQL в логах был читабельнее
format_sql: true
logging:
level:
# Печатает сами SQL-запросы
org.hibernate.SQL: DEBUG
# Печатает значения, которые подставляются вместо `?`
org.hibernate.orm.jdbc.bind: TRACE
Здесь три важные идеи. Во‑первых, ddl-auto: create-drop создаёт таблицы при старте и удаляет их при остановке, чтобы вы не ловили вопрос «почему у меня уже есть старые данные» и не думали, что JPA «дублирует» записи из вредности. Во‑вторых, org.hibernate.SQL=DEBUG показывает сами SQL-запросы. В‑третьих, org.hibernate.orm.jdbc.bind=TRACE показывает, какие значения подставляются вместо ?.
Запускать приложение удобно так, чтобы профиль был включён явно. Например, через переменную окружения:
# Явно включаем профиль dev, иначе не будет нужных настроек и/или таблиц
SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun
Если профиль не включить, вы легко попадёте в ситуацию «таблиц нет» или «SQL не видно», и весь смысл практики исчезнет. Это не потому, что вы плохи, — просто компьютер буквально понимает только то, что ему сказали.
3. Entity Category
Теперь самое приятное: ниже уже не отдельный кусок под одну аннотацию, а первая полная версия Category в проекте. В одном классе сходятся все уже знакомые части маппинга: таблица, id, контракт полей. Получается рабочий файл, который можно сразу сопоставлять с SQL-логом. Не пытайтесь делать Category «идеальной сущностью на века»: сейчас важнее, чтобы форма была прозрачной и читаемой.
Перфекционизм на этом шаге обычно приводит к тому, что студент делает красиво… и ничего не понимает.
Начнём с пакета по проектной структуре: com.example.shopdatajpa.catalog.entity. Файл назовём Category.java. Сразу используем Jakarta-импорты, потому что Spring Boot 4 и современный стек живут в мире jakarta.persistence.*, а не javax.persistence.*.
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
@Entity
@Table(name = "category")
public class Category {
}
Пока класс пустой, но две принципиальные вещи мы уже сделали. @Entity говорит JPA: «это часть persistence-модели». @Table(name="category") фиксирует имя таблицы и убирает любые догадки о том, как она должна называться. Для новичка правило «явно лучше, чем неявно» почти всегда работает.
Теперь добавим id и сразу применим ту политику, которую уже зафиксировали для проекта: SEQUENCE, понятное имя sequence и allocationSize = 1. Здесь нам не нужен второй большой спор IDENTITY против SEQUENCE; важно просто собрать из принятого решения рабочий класс.
import jakarta.persistence.SequenceGenerator;
// ...
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "category_seq")
@SequenceGenerator(
name = "category_seq",
sequenceName = "category_seq",
allocationSize = 1
)
private Long id;
Теперь добавим поля code, name, description, active. Здесь мы закрепим контракты через @Column: обязательность и уникальность для кода, обязательность для имени, разумную длину.
@Column(nullable = false, unique = true, length = 50)
private String code;
@Column(nullable = false, length = 100)
private String name;
@Column(length = 500)
private String description;
@Column(nullable = false)
private boolean active;
И наконец сделаем минимальный набор конструктора и геттеров/сеттеров. JPA нужен конструктор без параметров, а нам здесь важен рабочий файл без дополнительных трюков.
Ниже — первая полная версия Category, которую уже можно просто скопировать и собрать в проекте:
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
/**
* Простая сущность для первого цикла persist/find.
* Без связей, без сложной логики — чтобы было легко сопоставлять с SQL-логом.
*/
@Entity
@Table(name = "category") // Явно фиксируем имя таблицы в БД
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "category_seq")
@SequenceGenerator(
name = "category_seq",
sequenceName = "category_seq",
allocationSize = 1 // Учебный режим: предсказуемые id без "прыжков"
)
private Long id;
@Column(nullable = false, unique = true, length = 50) // Код обязателен и уникален
private String code;
@Column(nullable = false, length = 100) // Имя обязательно
private String name;
@Column(length = 500) // Описание можно не задавать
private String description;
@Column(nullable = false) // В базе будет NOT NULL
private boolean active;
// Конструктор без параметров нужен JPA
public Category() {
}
public Long getId() {
return id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
}
Чтобы не утонуть в деталях, полезно держать в голове маленькую табличку соответствий. Мы буквально описали, «какая колонка какая»:
| Java-поле | SQL-колонка (логически) | Важный контракт |
|---|---|---|
| id: Long | id | PK, генерируется через sequence |
| code: String | code | NOT NULL, UNIQUE, длина до 50 |
| name: String | name | NOT NULL, длина до 100 |
| description: String | description | nullable, длина до 500 |
| active: boolean | active | NOT NULL |
4. Entity Product и статус
С Product логика та же: это уже не фрагмент под одну аннотацию, а первый полный файл сущности. Просто применяем знакомые решения: цену держим в BigDecimal, а статус — в enum, который хранится строкой. Нам сейчас важна не новая теория типов, а то, как этот выбор превращается в рабочий маппинг.
Сначала заведём enum. Это отдельный файл, маленький и приятный, как котёнок (только без шерсти в клавиатуре).
package com.example.shopdatajpa.catalog.entity;
public enum ProductStatus {
ACTIVE,
ARCHIVED
}
Теперь создадим Product. Начнём с заготовки: @Entity и @Table(name="product").
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "product")
public class Product {
}
Добавляем id так же, как у Category, только с отдельной sequence.
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
// ...
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
@SequenceGenerator(
name = "product_seq",
sequenceName = "product_seq",
allocationSize = 1
)
private Long id;
Теперь поля. sku — уникальный и обязательный. name — обязательный. price — обязательный BigDecimal с precision и scale. status — enum с явным EnumType.STRING. То есть мы просто применяем два уже знакомых правила: деньги не кладём в double, а статус не храним ordinal-числом.
Ниже — первая полная версия Product в том же стиле:
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.EnumType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import java.math.BigDecimal;
/**
* Товар: простая сущность для отработки persist/find и чтения SQL-лога.
*/
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
@SequenceGenerator(
name = "product_seq",
sequenceName = "product_seq",
allocationSize = 1 // Учебный режим: предсказуемые id
)
private Long id;
@Column(nullable = false, unique = true, length = 64) // Уникальный артикул/sku
private String sku;
@Column(nullable = false, length = 120) // Название обязательно
private String name;
@Column(nullable = false, precision = 12, scale = 2) // Денежное значение: numeric(12,2)
private BigDecimal price;
@Enumerated(EnumType.STRING) // В БД храним строку ACTIVE/ARCHIVED, а не ordinal
@Column(nullable = false, length = 20)
private ProductStatus status;
// Конструктор без параметров нужен JPA
public Product() {
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public void setSku(String sku) {
this.sku = sku;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public ProductStatus getStatus() {
return status;
}
public void setStatus(ProductStatus status) {
this.status = status;
}
}
И снова можно держать в голове «контракт-таблицу», чтобы видеть, что именно мы просим от базы:
| Java-поле | SQL-колонка (логически) | Важный контракт |
|---|---|---|
| sku: String | sku | NOT NULL, UNIQUE, длина до 64 |
| price: BigDecimal | price | numeric(12,2), NOT NULL |
| status: ProductStatus | status | varchar, NOT NULL, хранится как строка |
5. persist(...) и INSERT
Пора сделать тот самый первый круг: создать объект, сохранить его и увидеть в логах SQL. Это лучший способ перестать бояться JPA и начать относиться к ней с уважением. Здесь мы сознательно используем EntityManager напрямую, потому что это самый честный и минимальный вариант: когда вы вызываете persist(), вы буквально говорите: «сделай так, чтобы этот объект стал строкой в таблице». В прикладном коде такие вызовы обычно прячут за репозиторием и сервисной транзакцией, но сейчас нам нужен максимально прозрачный низкоуровневый сценарий.
В Spring Boot нам удобно запустить код автоматически при старте приложения. Для этого подойдёт ApplicationRunner. Чтобы он не мешал в других режимах, повесим @Profile("dev"): в dev он выполнится, в других профилях — нет. Внутри будем работать через EntityManagerFactory, создавая EntityManager вручную. Это позволяет явно управлять транзакцией через EntityTransaction и очень хорошо укладывается в вашу SQL-картину мира из первых дней.
Сделаем класс в пакете, например, com.example.shopdatajpa.catalog.demo.
package com.example.shopdatajpa.catalog.demo;
import com.example.shopdatajpa.catalog.entity.Category;
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityTransaction;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@Component
@Profile("dev") // Запускаем демо-код только в dev-профиле
public class CatalogJpaSandboxRunner implements ApplicationRunner {
private final EntityManagerFactory emf;
public CatalogJpaSandboxRunner(EntityManagerFactory emf) {
this.emf = emf;
}
@Override
public void run(ApplicationArguments args) {
// 1) Сохраняем сущности (persist + commit -> INSERT в БД)
Long categoryId = saveCategory();
Long productId = saveProduct();
// 2) Печатаем id, чтобы понимать, что генерация ключей действительно сработала
System.out.println("Saved categoryId=" + categoryId); // Saved categoryId=1
System.out.println("Saved productId=" + productId); // Saved productId=1
}
private Long saveCategory() {
// Каждый блок создаёт отдельный EntityManager, чтобы было проще видеть SQL по шагам
try (EntityManager em = emf.createEntityManager()) {
EntityTransaction tx = em.getTransaction();
tx.begin(); // Без транзакции INSERT не будет зафиксирован
Category c = new Category();
c.setCode("books");
c.setName("Books");
c.setDescription("All kinds of books");
c.setActive(true);
em.persist(c); // Планируем INSERT
tx.commit(); // Фиксируем транзакцию -> INSERT реально улетает в БД
return c.getId(); // id уже заполнен после persist/commit (в зависимости от стратегии)
}
}
private Long saveProduct() {
try (EntityManager em = emf.createEntityManager()) {
EntityTransaction tx = em.getTransaction();
tx.begin();
Product p = new Product();
p.setSku("BOOK-001");
p.setName("Spring in Action");
p.setPrice(new BigDecimal("49.90")); // Для денег используем BigDecimal и строковый конструктор
p.setStatus(ProductStatus.ACTIVE);
em.persist(p);
tx.commit();
return p.getId();
}
}
}
На этом шаге важно понять смысл, а не запоминать каждую строчку. Мы создаём EntityManager, открываем транзакцию, создаём объект, вызываем persist, коммитим. Если вы делали raw SQL в Уровень 3, то по смыслу это почти то же самое — только вместо INSERT INTO ... руками мы просим JPA сделать это за нас.
Когда вы запустите приложение, то должны увидеть в логах SQL примерно такую картину (формат может отличаться, но идея будет ровно та же):
select nextval('category_seq')
insert into category (active, code, description, name, id) values (?, ?, ?, ?, ?)
select nextval('product_seq')
insert into product (name, price, sku, status, id) values (?, ?, ?, ?, ?)
И где-то рядом, если включён bind-лог, — значения, которые подставились вместо ?. Это тот момент, когда JPA перестаёт быть мифическим зверем: вы видите, что она просто генерирует обычный SQL.
6. find(...) и SELECT ... WHERE id = ?
Сохранение — только половина истории. Вторая половина — убедиться, что чтение работает так же честно: JPA должна сделать SELECT, вытащить колонки и разложить значения по полям объекта. Здесь особенно удобно проверить, что имена колонок, типы и маппинг enum совпали с ожиданиями. Если вы ошиблись в @Column или забыли @Enumerated(EnumType.STRING), именно на чтении проблема часто и всплывает.
Чтобы find(...) точно выполнил SQL-запрос, удобно делать чтение отдельным шагом и в отдельном EntityManager. Тогда это будет именно «чистое чтение из базы», а не ситуация «объект и так уже в памяти, поэтому JPA ничего не делает». Мы добавим в тот же runner методы загрузки и пару println, чтобы увидеть реальные значения.
Допишем в CatalogJpaSandboxRunner ещё два метода и расширим run():
@Override
public void run(ApplicationArguments args) {
Long categoryId = saveCategory();
Long productId = saveProduct();
Category loadedCategory = load(Category.class, categoryId);
Product loadedProduct = load(Product.class, productId);
System.out.println("Loaded category code=" + loadedCategory.getCode()); // Loaded category code=books
System.out.println("Loaded product sku=" + loadedProduct.getSku()); // Loaded product sku=BOOK-001
System.out.println("Loaded product status=" + loadedProduct.getStatus()); // Loaded product status=ACTIVE
}
private <T> T load(Class<T> type, Long id) {
try (EntityManager em = emf.createEntityManager()) {
EntityTransaction tx = em.getTransaction();
tx.begin();
T entity = em.find(type, id);
tx.commit();
return entity;
}
}
В SQL-логе вы должны увидеть запросы чтения, очень похожие на то, что мы писали руками в Уровне 2:
select c.id, c.active, c.code, c.description, c.name
from category c
where c.id = ?
select p.id, p.name, p.price, p.sku, p.status
from product p
where p.id = ?
И опять же: если включён bind-лог, вы увидите, что вместо ? подставился ваш id.
Эта часть особенно важна психологически. Многие новички думают: «JPA же что-то там хранит, может, оно вообще не в базу пишет?» А тут вы видите: нет, всё честно. Был INSERT. Потом был SELECT. Это ровно то, что вы и хотели.
7. Осознанное чтение SQL-лога
SQL-лог очень легко превратить в шум: «там много строк, я ничего не понял, значит JPA работает». Нам нужен обратный подход: лог — это инструмент проверки. Поэтому полезно заранее договориться, что именно вы ищете в SQL и как связываете это с маппингом. Это как с кассовым чеком: помнить каждую цифру не нужно, но полезно замечать, если хлеб почему-то пробили как телевизор.
Во-первых, у sequence-генерации обычно будет отдельный шаг — запрос значения из sequence. В PostgreSQL это выглядит как select nextval('...'). Важно понимать, что это не «лишний запрос из вредности», а реальная механика: база выдаёт следующее значение, JPA кладёт его в id, а потом делает INSERT. Когда вы видите nextval, можете спокойно сказать: «ага, @GeneratedValue(SEQUENCE) работает».
Во-вторых, в INSERT вы сравниваете список колонок и порядок значений. Например, если видите, что в category вставляются code, name, description, active, — отлично. Если вдруг пропало поле active или оно называется странно, это повод проверить @Column(name="...") или naming strategy.
В-третьих, вы проверяете, что обязательные поля действительно вставляются в рамках логики NOT NULL. Прямо сейчас мы не обсуждаем constraints на уровне базы подробно, но уже сегодня можно поймать простую ошибку: например, если вы не установили name, а @Column(nullable=false) стоит, при вставке вы получите ошибку. Это полезная обратная связь: «моя модель реально требует данные».
В‑четвёртых, вы проверяете enum. Если в bind-логах видите значение ACTIVE как строку, значит EnumType.STRING сработал. Если увидели число 0, значит enum хранится как ordinal, и это повод остановиться и исправить. Сейчас это ещё легко; потом в живой базе это становится дорогим удовольствием.
Наконец, вы проверяете BigDecimal. В логах вы увидите что-то вроде 49.90. Это нормально и ожидаемо. Если бы цена хранилась в double, вы могли бы увидеть значение с «хвостом», и это тот случай, когда компьютер не ошибся — он просто честно сделал то, что вы попросили.
8. Проверка данных SQL-запросом
После того как JPA-сценарий отработал, полезно сделать короткую «проверку реальности»: открыть любую SQL-консоль — psql, DBeaver, DataGrip, что угодно — и выполнить простой SELECT. Это не потому, что мы не доверяем Hibernate. Это потому, что инженерная привычка «проверяй факт» спасает от огромного числа будущих багов. Плюс это отличный мост между SQL-мышлением и ORM.
Например, можно выполнить:
select id, code, name, active
from category
order by id;
И ожидать увидеть примерно такие данные:
-- id | code | name | active
-- ----+-------+-------+--------
-- 1 | books | Books | true
А для товаров:
select id, sku, name, price, status
from product
order by id;
Пример ожидаемого результата по смыслу:
-- id | sku | name | price | status
-- ----+----------+-----------------+-------+---------
-- 1 | BOOK-001 | Spring in Action| 49.90 | ACTIVE
Обратите внимание на две вещи. Во‑первых, вы видите, что в колонке status действительно хранится строка ACTIVE, а не число. Во‑вторых, цена лежит как нормальное десятичное значение с двумя знаками. Это именно то, что мы «заказали» через precision/scale.
Да, в учебном режиме с create-drop данные исчезнут после остановки приложения — и это нормально. Цель сейчас не «накопить базу», а увидеть, что модель и SQL совпадают.
9. Типичные ошибки при persist/find
На первой практике почти все ошибаются — это нормально. Важно не «не ошибаться», а научиться узнавать ошибку по симптомам и понимать, где именно она живёт: в аннотациях, в конфиге, в типах полей или просто в том, что приложение запущено не тем профилем. Ниже — набор грабель, на которые наступают чаще всего. И почти все они отлично лечатся чтением SQL-лога.
Ошибка №1: импортированы javax.persistence.* вместо jakarta.persistence.*.
В современном Spring Boot 4 вы живёте в Jakarta-мире. Если по привычке или по старой статье импортировать javax.persistence.Entity, IDE может подсветить странные ошибки, а проект — вообще не собраться. Симптом обычно простой: компиляция падает или аннотации «не находятся». Лечение — заменить импорты на jakarta.persistence.* и перестать открывать туториал 2016 года как источник правды.
Ошибка №2: забыли ddl-auto или запустили без профиля dev, и таблиц нет.
Классический симптом — при первом INSERT вы получаете сообщение от PostgreSQL в духе «relation category does not exist». JPA тут ни при чём: база честно говорит «такой таблицы нет». Проверьте, что активирован dev-профиль и что в нём включён режим генерации схемы — в рамках учебной практики.
Ошибка №3: забыли @Entity или забыли @Id.
Если забыть @Entity, Hibernate не будет воспринимать класс как сущность, и дальше посыпятся ошибки маппинга или сканирования. Если забыть @Id, ошибка обычно звучит ещё прямолинейнее: JPA не умеет работать с сущностью без идентификатора. Это как попытаться вести каталог товаров без уникального «внутреннего номера» — вы просто не сможете отличить один товар от другого.
Ошибка №4: не указали allocationSize = 1, и id выглядят «прыгающими».
Это не баг, а следствие оптимизации, но для первой практики выглядит пугающе: «почему у меня товары получают id 1, 51, 101?» Если вы хотите максимально спокойный учебный режим, фиксируйте allocationSize = 1. И при этом не делайте из id бизнес-значение и не ждите, что он обязан быть «красивым» и непрерывным.
Ошибка №5: цена сделана double, и в базе или логах появляются странные дроби.
Если вы где-то заменили BigDecimal на double «для простоты», вы почти гарантированно получите историю про 49.900000000000006. Это не «сломалась база». Это типичная проблема бинарных дробей в floating-point. Для денег в persistence-модели — BigDecimal, и лучше сразу в виде строки new BigDecimal("49.90"), как мы и сделали.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ