JavaRush /Курсы /Hibernate deep-dive /Как выделить ProductCatego...

Как выделить ProductCategoryAssignment

Hibernate deep-dive
12 уровень , 1 лекция
Открыта

1. Link entity: правильная модель

Когда разработчик впервые слышит «давай выделим таблицу связи в отдельную сущность», мозг иногда реагирует как на просьбу вручную переписать 300 строк конфигов YAML. Но сейчас мы не усложняем систему, а перестаём прятать реальный объект модели. Если у связи есть свои поля, время и правила, ей нужен собственный класс.

ProductCategoryAssignment — это не технический мусор. Это отдельный факт предметной области: товар назначен в категорию. Значит, дальше мы уже думаем не «у товара просто список категорий», а «у товара есть набор назначений, и каждое назначение указывает на конкретную категорию».

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

Схема ProductAssignmentCategory

Перед тем как писать аннотации, полезно на минуту остановиться и представить, какой объектный граф мы хотим видеть в памяти приложения. В @ManyToMany мы думали так: у товара есть список категорий. В link entity модели мы думаем иначе: у товара есть список назначений (assignments), и каждое назначение указывает на конкретную категорию. Это небольшая перестройка мышления, но она очень быстро начинает окупаться.

Ниже — схема, которая показывает разницу «одна коллекция» против «явная сущность связи»:

flowchart LR
    P[Product] -- "1..*" --> A[ProductCategoryAssignment]
    A -- "*..1" --> C[Category]

    P ---|"categoryAssignments"| A
    C ---|"productAssignments"| A

Важный момент: в этой модели assignment — это центр управления связью, потому что именно он содержит два внешних ключа (product_id, category_id). А значит, именно он становится owning side для обеих @ManyToOne-ссылок. Product и Category будут иметь @OneToMany(mappedBy = ...) для навигации, но реальная запись внешних ключей идёт через assignment.

Чтобы не запутаться в названиях, сразу договоримся о терминах в коде. На Product коллекция должна называться примерно categoryAssignments, а не categories, потому что в ней лежат не категории, а объекты назначения. На Category логично иметь productAssignments, чтобы можно было, если нужно, увидеть «какие товары назначены в эту категорию» — опять же через assignments.

3. Сущность ProductCategoryAssignment

Сейчас мы сделаем самый важный шаг дня: превратим скрытую join-table в полноценную JPA entity. Сначала соберём именно минимальный каркас: без дополнительной логики поведения, но уже с честной таблицей связи, двумя @ManyToOne-ссылками и отдельным объектом в памяти.

Практически это означает, что у нас появится класс ProductCategoryAssignment с @Entity, таблица в БД, и две @ManyToOne-ссылки. Мы не пытаемся «сразу сделать идеально всё на свете» — нам достаточно минимально корректной структуры, на которую дальше можно будет опереться.

Минимальный каркас: @Entity, @Table, id

Начнём с того, что любой entity в JPA должен иметь идентификатор. Да, join-table в чистом виде часто живёт без surrogate id и использует составной ключ (product_id, category_id). Но в учебном проекте (и в реальных проектах тоже довольно часто) добавление id упрощает жизнь: легче ссылаться на конкретное назначение, проще удалять «вот эту запись связи», проще работать в логах и дебаге.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "product_category_assignment")
public class ProductCategoryAssignment {

    @Id
    @GeneratedValue
    // Surrogate id: упрощает ссылки, дебаг и точечное удаление конкретного назначения
    private Long id;
}

Обрати внимание на jakarta.persistence.*: в Spring Boot 4 мы живём в мире Jakarta, и javax.persistence — это уже прошлое (как DVD-диски: где-то ещё встречаются, но лучше не начинать коллекцию).

Две ссылки @ManyToOne: product и category

Теперь добавляем главное. Assignment должен ссылаться и на товар, и на категорию. Именно эти поля будут формировать внешние ключи в таблице product_category_assignment. Здесь очень полезно вспомнить модуль про fetching: @ManyToOne по умолчанию EAGER, и если мы оставим дефолт, то назначение категории начнёт «таскать за собой» и товар, и категорию без спроса. После дней про N+1 и fetch-design это уже звучит как плохая идея, поэтому выставим LAZY осознанно.

import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

// Owning side связи с Product: именно это поле пишет product_id в таблицу assignment
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false) // nullable=false: защита на уровне БД
private Product product;

// Owning side связи с Category: именно это поле пишет category_id в таблицу assignment
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "category_id", nullable = false) // nullable=false: защита на уровне БД
private Category category;

Здесь есть два слоя «контроля здравого смысла». optional = false — это наш сигнал ORM: «assignment без товара или без категории бессмысленен». А nullable = false на @JoinColumn — это уже просьба к схеме БД: «не позволяй хранить битые связи». В deep-dive курсе мы постоянно держим в голове две истины: модель должна быть корректной в памяти, и схема должна поддерживать корректность на уровне данных.

Поля связи: sortOrder и assignedAt

Мы не будем прямо сейчас превращать эту лекцию в лекцию 4 про инварианты, но важно уже на уровне класса показать, что связь не пустая. Поэтому добавим поля sortOrder и assignedAt. Даже если пока мы их не валидируем идеально, сам факт «эти поля живут здесь» дисциплинирует модель.

import jakarta.persistence.Column;
import java.time.Instant;

// Порядок категории внутри товара (например, для вывода в каталоге)
@Column(name = "sort_order", nullable = false)
private int sortOrder;

// Момент, когда назначение было создано (а не когда "вспомнили обновить товар")
@Column(name = "assigned_at", nullable = false)
private Instant assignedAt;

Если у тебя возник вопрос «почему Instant, а не LocalDateTime», то ты на правильном пути. Instant — это момент времени в UTC, и в большинстве backend-сценариев он проще и честнее. Но не будем сейчас уходить в отдельный курс «временные зоны против разработчиков»: нам достаточно того, что Instant хорошо подходит под «момент назначения».

Конструктор JPA и «нормальный» конструктор

JPA любит создавать объекты рефлексией, поэтому entity обычно нужен конструктор без аргументов. Мы сделаем его protected, чтобы случайный код не создавал полупустые assignment-объекты «просто так». Сами поля product, category, sortOrder и assignedAt уже объявлены выше; здесь важно зафиксировать, как assignment вообще рождается как отдельный объект.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.time.Instant;

@Entity
public class ProductCategoryAssignment {

    @Id
    @GeneratedValue
    private Long id;

    // Конструктор для JPA: ORM создаёт объект рефлексией
    protected ProductCategoryAssignment() {
    }

    // Минимальный конструктор: собираем связь целиком без guard-валидации
    public ProductCategoryAssignment(Product product,
                                     Category category,
                                     int sortOrder,
                                     Instant assignedAt) {
        this.product = product;
        this.category = category;
        this.sortOrder = sortOrder;
        this.assignedAt = assignedAt;
    }
}

На этом шаге достаточно того, что assignment создаётся как отдельная сущность, а не как побочный эффект списка категорий.

4. Обновляем Product: categoryAssignments

Сущность связи сама по себе полезна, но её нужно встроить в навигацию доменной модели. На практике это означает: у Product больше не будет List<Category> categories. Вместо этого появится List<ProductCategoryAssignment> categoryAssignments. Это тот момент, когда многие впервые чувствуют лёгкий дискомфорт: «как же я теперь получу список категорий?». Спокойно. Получишь. Просто теперь это будет честный путь: через assignments, которые и являются источником правды.

@OneToMany(mappedBy="product"): навигация + lifecycle

С точки зрения маппинга логика такая. Assignment содержит поле product и оно owning side. Значит, на Product мы создаём коллекцию, которая говорит: «я — обратная сторона, меня обновляют через ProductCategoryAssignment.product». Это ровно и выражается через mappedBy = "product".

import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@OneToMany(
    mappedBy = "product",               // обратная сторона, FK хранится в assignment.product
    cascade = CascadeType.ALL,           // товар управляет жизненным циклом своих назначений
    orphanRemoval = true                // удалили assignment из коллекции -> удалили строку связи
)
private List<ProductCategoryAssignment> categoryAssignments = new ArrayList<>();

Здесь два важных решения, и оба опираются на предыдущий уровень. Мы ставим cascade = ALL, потому что assignment — это действительно «дочерняя» сущность товара в рамках нашего сценария управления каталогом: товар управляет своим набором назначений. И мы ставим orphanRemoval = true, потому что удаление assignment из коллекции товара должно означать удаление строки связи из таблицы. Обрати внимание: это удалит assignment, но не удалит категорию. Категория — отдельная справочная сущность, она не «принадлежит» товару.

Дисциплина: пока без «удобного» списка категорий

Очень хочется написать в Product метод типа getCategories() и вернуть categoryAssignments.stream().map(a -> a.getCategory()).toList(). Это, в целом, нормально, но сегодня важно удержать дисциплину: в модели теперь первичны assignments. Если мы прямо сейчас начнём прятать их обратно за «удобной» коллекцией категорий, то рискнём вернуться к старой привычке: менять связь через список категорий и снова терять контроль над sortOrder и assignedAt.

Поэтому на уровне полей мы держим только categoryAssignments. А «удобные» методы доступа сделаем позже и аккуратно, когда у нас появятся helper-методы управления связью. Это похоже на ремонт в квартире: сначала меняем проводку, а уже потом красиво клеим обои. Если наоборот — будет красиво ровно до первого короткого замыкания.

5. Обновляем Category: productAssignments

Теперь симметричный шаг: на стороне Category мы тоже хотим видеть assignments, которые ссылаются на эту категорию. Но здесь меняется смысл. Категория — справочник, и assignments «приходят и уходят», но категория не должна пытаться каскадно управлять чужими товарами или чужими связями без необходимости. Поэтому здесь обычно хватает простой @OneToMany(mappedBy="category") коллекции для навигации.

import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@OneToMany(mappedBy = "category") // обратная сторона, FK хранится в assignment.category
private List<ProductCategoryAssignment> productAssignments = new ArrayList<>();

Заметь: никакого cascade = ALL и никакого orphanRemoval здесь по умолчанию. Мы не хотим ситуации, когда кто-то удалил assignment из category.productAssignments, а Hibernate решил «ага, orphanRemoval — значит удаляем» и внезапно начал менять связи со стороны, которая в нашем сценарии не должна быть главным контролёром.

С практической точки зрения это правило очень простое: товар управляет «какие категории у меня есть», а категория в основном нужна как справочник и как точка навигации. В реальном проекте могут быть другие сценарии, но в нашем Commerce Persistence Lab мы фиксируем именно такой, потому что он хорошо показывает lifecycle-мысль, которую мы тренируем.

6. Таблица и миграция Flyway

Когда мы рисуем аннотации, легко забыть, что Hibernate — это не волшебник, который из воздуха создаёт схему (по крайней мере, в нашем курсе он точно не должен так делать). У нас схема управляется Flyway-миграциями, значит под новую сущность нужна таблица.

Ниже — минимальный вариант DDL. Он не пытается учесть все будущие инварианты, но создаёт то, что нужно для жизни entity: id, два FK и два поля связи.

create table product_category_assignment (
    id bigserial primary key, -- surrogate id для удобства работы с назначением
    product_id bigint not null references product(id), -- FK на товар
    category_id bigint not null references category(id), -- FK на категорию
    sort_order integer not null, -- порядок отображения внутри товара
    assigned_at timestamp with time zone not null -- момент назначения (UTC-момент)
);

Если тебе очень хочется сразу добавить уникальность пары (product_id, category_id) — желание правильное, просто мы закрепим это как отдельный инвариант чуть позже. Сейчас нам важно, чтобы сама механика link entity заработала и чтобы мы могли увидеть, как меняется навигация и поведение ORM.

Сервисный код до helper-методов

После выделения link entity сервисный код временно станет чуть более «разговорчивым». Это нормально: мы вынули скрытую часть модели наружу, и теперь код обязан её назвать. На этом шаге полезно увидеть механику в лоб, без сокрытия её за дополнительными методами.

Вот наивный (временный) стиль, который показывает механику:

import java.time.Instant;

// Создаём именно "назначение", а не "просто добавляем категорию в список"
ProductCategoryAssignment a =
        new ProductCategoryAssignment(product, category, 10, Instant.now());

// Пока helper-методов нет, обе стороны связи приходится синхронизировать руками
product.getCategoryAssignments().add(a);
category.getProductAssignments().add(a);

Этот кусок кода как раз и создаёт ощущение «ну вот, стало больше строчек». Но посмотри на это с другой стороны: теперь у нас есть место, где можно поставить правило «нельзя назначить одну и ту же категорию дважды», где можно контролировать sortOrder, где можно задать момент назначения, и где можно сделать удаление точечным (удаляем assignment, а не играем в “пересобрать всю таблицу связи”).

И тут же становится видна следующая боль: сама структура уже честная, но код всё ещё очень ручной. Забыли одну сторону связи — и граф в памяти начал врать.

Проверка через flush() и SQL-лог

Мы уже привыкли к главному ритуалу deep-dive курса: если мы что-то поменяли в mapping, мы обязаны увидеть, какой SQL уйдёт в БД. Иначе есть риск, что мы написали красивый код, а Hibernate в это время живёт своей жизнью, тихо делая лишние DELETE/INSERT и подмигивая вам из логов.

Для быстрой проверки достаточно простого сервисного фрагмента с flush():

import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import java.time.Instant;

@Transactional
public void assignCategory(Product product, Category category, EntityManager em) {
    // Создаём отдельный объект связи (link entity) — это и есть "источник правды" по связи
    ProductCategoryAssignment a =
            new ProductCategoryAssignment(product, category, 10, Instant.now());

    // Пока helper-методов нет, lifecycle и обе стороны графа поддерживаем руками
    product.getCategoryAssignments().add(a);
    category.getProductAssignments().add(a);

    // Принудительно отправляем изменения в БД, чтобы увидеть SQL прямо сейчас
    em.flush(); // тут ожидаем INSERT в product_category_assignment
}

Если SQL-лог настроен, ты должен увидеть что-то в духе:

-- Ожидаемый SQL: добавление новой строки в таблицу назначений
insert into product_category_assignment
(product_id, category_id, sort_order, assigned_at, id)
values (?, ?, ?, ?, ?);

И вот это ощущение «я понимаю, какую строку я добавляю» — одна из главных побед link entity подхода. Мы перестаём гадать, что происходит с join-table. Мы начинаем управлять ею как обычными данными модели.

SQL уже читается лучше, чем в @ManyToMany, но сам код просит следующего шага: убрать ручную синхронизацию в саму модель.

7. Типичные ошибки при выделении ProductCategoryAssignment

Ошибка №1: оставить одновременно @ManyToMany categories и categoryAssignments.
Это почти гарантированный способ получить рассинхронизацию модели: часть кода будет менять связь через старую коллекцию, часть — через assignment, и в итоге у Hibernate появятся «две правды». В какой-то момент ты увидишь лишние DELETE/INSERT в таблице связи и начнёшь подозревать заговор. Заговора нет — просто модель стала двуглавой, а двум головам трудно договориться.

Ошибка №2: включить каскады на сторону Category “на всякий случай”.
Иногда рука тянется поставить cascade = ALL везде, чтобы «точно сохранялось». В случае со справочниками это опасно: категории часто shared (одна категория используется многими товарами), и каскадная модель жизненного цикла здесь обычно не отражает реальность. Лучше иметь одну явную точку управления assignment’ами (в нашем сценарии — со стороны товара), чем две конкурирующие.

Ошибка №3: забыть, что @ManyToOne по умолчанию EAGER.
Если не указать fetch = LAZY, можно внезапно получить лишнюю загрузку Product и Category при работе с assignment’ами, и это будет особенно неприятно в списках. Мы уже проходили fetching в модуле 2, поэтому сейчас самое время применить знание: дефолты JPA не всегда совпадают с твоими performance-ожиданиями.

Ошибка №4: сделать assignment-объект «полупустым» и надеяться, что Hibernate догадается.
Иногда assignment создают, кладут в коллекцию, но забывают поставить product или category. В памяти это выглядит как «ну сейчас как-нибудь сохранится», а в БД это либо упадёт на NOT NULL, либо (если ограничения мягкие) создаст мусорные строки. Assignment должен быть полноценной записью связи, а значит ему нужны обе ссылки.

Ошибка №5: назвать коллекции так, будто там лежат не assignments, а связанные сущности.
Если в Product поле называется categories, но тип у него List<ProductCategoryAssignment>, это будет постоянно вводить в заблуждение. В таких местах баги рождаются не из Hibernate, а из обычной человеческой усталости: читаешь код, ожидаешь одно, а там другое. Хорошее имя коллекции — это половина контроля над моделью, особенно в учебном проекте, где мы тренируем предсказуемость поведения.

1
Задача
Hibernate deep-dive, 12 уровень, 1 лекция
Недоступна
Выделение `ProductCategoryAssignment` в отдельную сущность
Выделение `ProductCategoryAssignment` в отдельную сущность
1
Задача
Hibernate deep-dive, 12 уровень, 1 лекция
Недоступна
Навигация через assignment-объекты вместо прямого списка тем
Навигация через assignment-объекты вместо прямого списка тем
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ