JavaRush /Курси /Spring Data JPA /Обмеження: UNIQUE,...

Обмеження: UNIQUE, FOREIGN KEY, NOT NULL

Spring Data JPA
Рівень 24 , Лекція 1
Відкрита

1. Інваріант і «неможливі» дані

Перш ніж додавати до SQL «красиві» обмеження, корисно зупинитися й запитати себе: а що саме ми захищаємо? Constraint — це не прикраса таблиці й не «бо так заведено». Constraint — це спосіб сказати базі: «ось цей стан даних узагалі не має існувати». І якщо застосунок спробує записати такий стан, база має відповісти твердим «ні» — навіть якщо в коді хтось утомився, помилився або просто написав save() не в тому місці.

У mini-shop у нас доволі життєва доменна картина. У товару є бізнес-ідентифікатор (sku), а не лише технічний id. У категорії є code, бо людям зручно посилатися на «ELECTRONICS», а не на «42». Замовленню потрібен унікальний orderNumber, бо замовлення без номера — як таксі без номера: начебто їде, але потім спробуйте довести, що це було ваше. Також є очевидна звʼязність даних: товар зобовʼязаний належати до категорії, а позиція замовлення — посилатися на замовлення і на товар.

Щоб не перетворювати лекцію на філософію, зафіксуємо кілька інваріантів — тобто того, що вважаємо «неможливим», — і те, якими обмеженнями вони зазвичай захищаються:

Інваріант домену Що забороняємо в даних Constraint, що «тримає удар»
Category.code унікальний дві категорії з одним code UNIQUE(category.code)
Category.name обовʼязковий NULL у name NOT NULL(category.name)
Product.sku унікальний два товари з одним sku UNIQUE(product.sku)
товар зобовʼязаний мати категорію category_id = NULL або посилання «в нікуди» NOT NULL(product.category_id) + FOREIGN KEY(product.category_id)
OrderItem не існує без замовлення order_id = NULL або посилання на неіснуюче замовлення NOT NULL(order_item.order_id) + FOREIGN KEY(order_item.order_id)
OrderItem не існує без товару product_id = NULL або посилання на неіснуючий товар NOT NULL(order_item.product_id) + FOREIGN KEY(order_item.product_id)
CustomerOrder.orderNumber унікальний два замовлення з одним номером UNIQUE(customer_order.order_number)

Зверніть увагу на одну важливу думку: один інваріант часто вимагає комбінації обмежень. Наприклад, «товар зобовʼязаний мати категорію» — це не лише FOREIGN KEY, тому що FK сам по собі допускає NULL, якщо колонка nullable. Тому в реальному проєкті «обовʼязковий звʼязок» майже завжди означає «NOT NULL + FOREIGN KEY».

2. NOT NULL: обовʼязкові поля й обовʼязкові звʼязки

NOT NULL виглядає найпростішим обмеженням, тому новачки часто ставляться до нього як до чогось факультативного: «ну ми ж у Java перевіримо». Але в NOT NULL є дуже корисна властивість: він перетворює частину багів на гарантовану помилку запису, а не на тихо зламані дані, які спливуть за тиждень у звітах або в продакшені. Простими словами, NOT NULL — це той самий «ой, ні» на рівні бази, який не дає вам зберегти напівпорожній рядок і зробити вигляд, що все нормально.

NOT NULL у схемі

Почнімо з простого SQL. Припустімо, ми хочемо зафіксувати: product.sku і product.name мають бути обовʼязковими. У Flyway це виглядає приблизно так (фрагмент міграції; файл може бути, наприклад, V6__product_not_null.sql або частиною вашої «загальної» міграції з правками):

-- Робимо SKU обовʼязковим: без нього товар у домені не існує
alter table product
    alter column sku set not null;

-- Робимо імʼя обовʼязковим: інакше отримаємо товари без назви
alter table product
    alter column name set not null;

Це проста, але дуже сильна річ: тепер жоден INSERT/UPDATE не зможе записати NULL у ці колонки. Неважливо, зробив це ваш сервіс, скрипт, тимчасова адмінка чи тест, написаний о 4 ранку.

Трохи цікавіша ситуація зі звʼязками. Наприклад, за доменом Product зобовʼязаний належати Category. На рівні таблиці це означає: колонка category_id обовʼязкова.

-- Обовʼязковий звʼязок: товар не може існувати без категорії
alter table product
    alter column category_id set not null;

Якщо category_id nullable, то ви фактично допускаєте товар «без категорії». Іноді це нормально, наприклад для чорновика, але в нашому домені це не те, що ми хочемо.

NOT NULL в entity

У Java-моделі ми теж відображаємо обовʼязковість. Важливо правильно зрозуміти роль цих анотацій: коли ми живемо з Flyway, реальний захист створюється міграцією, а анотації в entity — це домовленість у коді й підказка Hibernate та людині, яка читає код.

Фрагмент Product:

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

@Entity
public class Product {

    // SKU — бізнес-ідентифікатор, у домені він обовʼязковий
    @Column(name = "sku", nullable = false, length = 64)
    private String sku;

    // Обовʼязковий звʼязок в обʼєктній моделі: category не має бути null
    @ManyToOne(optional = false)
    // І на рівні колонки теж фіксуємо обовʼязковість, але справжній захист дає міграція
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
}

Тут є два місця, які новачок легко плутає:

@Column(nullable = false) і @JoinColumn(nullable = false) говорять про колонку. Це зручно і для валідації схеми (ddl-auto=validate), і просто як читабельна документація.

@ManyToOne(optional = false) — це радше про семантику звʼязку у світі JPA: обʼєкт category не має бути null. Це допомагає інструментам і іноді впливає на поведінку ORM. Але якщо в базі колонка все ще nullable — справжньої гарантії ви не отримали.

NULL і «порожній рядок»

Тут часто виникає пастка: NOT NULL захищає лише від NULL, але не від порожнього рядка ''. Тобто база цілком може прийняти name = '', якщо ви не додали додаткових обмежень. Це нормально: NOT NULL — це саме «значення має бути», а не «значення має бути осмисленим».

Якщо вам потрібно «не порожньо й не пробіли», це вже частіше зона Bean Validation (@NotBlank) і сервісної логіки. Ми не йдемо зараз у повний зоопарк валідацій, але важливо памʼятати: NOT NULL — про структурну цілісність, а «не порожньо» — про якість значення.

3. UNIQUE: коли значення має бути єдиним

UNIQUE — це той самий охоронець на вході до клубу: його не цікавить ваш настрій, він просто не пустить другу людину з таким самим паспортом. І це чудово, бо унікальність — одна з найчастіших причин появи дивних дублікатів, коли система наче працює, але потім раптом виявляється, що у вас два товари з однаковим SKU, і обидва правильні, і обидва брали участь у замовленнях. Вітаю: ви створили загадку для археологів даних.

Бізнес-ключ vs технічний id

У наших таблицях майже всюди є id bigint primary key. Це технічний ключ, і він унікальний за визначенням.

Але бізнесу зазвичай важливіше інше:

— для категорії важливий code (ELECTRONICS, BOOKS);

— для товару важливий sku (SKU-1, SKU-IPHONE-15);

— для замовлення важливий orderNumber (ORD-2026-000123).

Якщо ці значення не унікальні, система починає брехати. Причому робитиме вона це спокійно: не впаде, а просто працюватиме так, що потім ніхто не зрозуміє, що саме сталося.

UNIQUE у Flyway

Додамо унікальність sku на рівні схеми (знову ж таки, шматок міграції; імена файлів умовні):

-- Забороняємо два товари з однаковим SKU
alter table product
    add constraint uk_product_sku unique (sku);

Аналогічно для категорії:

-- Забороняємо дві категорії з однаковим code
alter table category
    add constraint uk_category_code unique (code);

І для замовлення:

-- Забороняємо два замовлення з однаковим номером
alter table customer_order
    add constraint uk_customer_order_number unique (order_number);

Тут варто привчити себе до неймінгу. Нічого магічного в назвах немає, але хороший стиль дуже допомагає під час діагностики: коли constraint називається uk_product_sku, ви одразу розумієте, що саме зламалося. Якщо ж він називається product_sku_key або something_42, ви дивитиметеся на це як на ребус.

До речі, у PostgreSQL унікальний constraint майже завжди реалізується через унікальний індекс. Тобто фактично база не лише забороняє дублікат, а й робить пошук за цим полем швидким. Це приємний бонус, але не варто перетворювати UNIQUE на «давайте індексувати все підряд». Ми ставимо унікальність там, де вона відображає правило домену.

UNIQUE в entity

У Java-коді теж можна позначити унікальність. Найпростіший варіант — unique = true:

import jakarta.persistence.Column;
import jakarta.persistence.Entity;

@Entity
public class Category {

    // code — бізнес-ключ категорії, тому він і обовʼязковий, і унікальний
    @Column(name = "code", nullable = false, unique = true, length = 64)
    private String code;
}

Це читабельно, але я хочу, щоб ви закріпили думку: після переходу на Flyway це не головний механізм захисту. Навіть якщо в entity написано unique = true, а в міграції constraint не створено, база прийматиме дублікати. І навпаки: якщо в міграції constraint є, а в entity ви забули unique = true, то база все одно захистить дані, але Hibernate на ddl-auto=validate може не сваритися, залежно від того, як ви вмикали перевірку. Тому краще тримати обидва рівні узгодженими.

Якщо хочете більш явно і на рівні схеми, можна описувати унікальність через @Table. Але в навчальному проєкті це зазвичай уже надмірно, і багато новачків починають плутатися: «а що краще». Для початку достатньо unique = true у полі й constraint у міграції.

UNIQUE і pre-check existsBySku(...)

Сервісний pre-check потрібен, щоб дати зрозумілу реакцію на сценарій. Але він не дає залізної гарантії через гонки.

Уявіть дві паралельні транзакції — два користувачі, два потоки, два запити, неважливо. Обидві роблять existsBySku("SKU-1") і бачать «false». Потім обидві намагаються вставити рядок. І лише в момент запису база має сказати: «так, стоп, другий такий SKU я не приймаю».

Ось чому UNIQUE — це не заміна сервісного pre-check, а pre-check — не заміна UNIQUE. Вони працюють разом: сервіс робить поведінку зрозумілою, база — правильною.

4. FOREIGN KEY: цілісні звʼязки

Якщо UNIQUE захищає від дублікатів, то FOREIGN KEY захищає від посилань у порожнечу. І це, чесно кажучи, одна з головних причин, чому реляційні бази взагалі настільки корисні: вони не дають вам випадково побудувати світ, де order_item.product_id = 123, але товару 123 не існує. В обʼєктному світі таке теж можна влаштувати, але там це зазвичай виглядає як «у нас є посилання на обʼєкт, який ніхто не може знайти», тобто теж біль, тільки іншого формату.

FK на прикладі product.category_id

У схемі це виглядає дуже просто:

-- Товар має посилатися на існуючу категорію
alter table product
    add constraint fk_product_category
    foreign key (category_id) references category(id);

Тепер база не дозволить:

— вставити товар із category_id, якого немає в таблиці category;

— оновити category_id на неіснуюче значення.

А якщо ще й колонка category_id у нас NOT NULL, то товар без категорії теж не зʼявиться.

FK у замовленнях: OrderItem

Позиції замовлення — це класика для FK. OrderItem без замовлення не має сенсу, OrderItem без товару теж.

Фрагмент міграції:

-- Позиція замовлення має посилатися на існуюче замовлення
alter table order_item
    add constraint fk_order_item_order
    foreign key (order_id) references customer_order(id);

-- Позиція замовлення має посилатися на існуючий товар
alter table order_item
    add constraint fk_order_item_product
    foreign key (product_id) references product(id);

І тут важливо памʼятати про обовʼязковість: якщо в домені позиція завжди зобовʼязана мати замовлення і товар, то order_id і product_id мають бути NOT NULL. FK сам по собі не забороняє NULL.

Карта звʼязків

Іноді корисно подивитися на це як на просту діаграму:

erDiagram
    CATEGORY ||--o{ PRODUCT : містить
    CUSTOMER_ORDER ||--o{ ORDER_ITEM : має
    PRODUCT ||--o{ ORDER_ITEM : замовляється

Це не про JPA — це про здоровий глузд даних. JPA просто повторює те, що ми вирішили на рівні схеми.

Видалення і FK: обмеження

Ось тут і проявляється практичний сенс FK. За замовчуванням PostgreSQL працює так: якщо на батьківський запис ще посилаються дочірні рядки, видалити його не можна. І це дуже корисно: база не дає тихо порвати звʼязки, а сервіс потім може перевести таку відмову в зрозумілу помилку сценарію.

Наприклад, якщо у вас є товари в категорії, то спроба видалити категорію призведе до помилки на рівні БД. І це, загалом, добра річ: база захищає звʼязність даних краще, ніж будь-яке «якщо» в сервісі.

Є варіанти поведінки через ON DELETE ... (наприклад, CASCADE), але тут легко наступити на граблі: ON DELETE CASCADE для категорії означало б «видаляємо категорію — видаляємо всі товари в ній». Іноді це потрібно, але частіше це шлях до випадкового масового видалення, після якого залишається лише філософськи дивитися на монітор і тихо шепотіти: «ну зате constraint був красивий».

У нашому mini-shop логіка зазвичай така: категорію не можна видалити, доки в ній є товари. Ми або переводимо категорію в active = false, або переносимо товари до іншої категорії, або явно чистимо залежні дані через усвідомлений сценарій використання. Це більш керовано.

5. Flyway: джерело правди та entity

Коли ви тільки починаєте з JPA, великою спокусою є думка: «я поставлю анотацію — і база якось сама стане правильною». Це працює, доки ви живете на ddl-auto=create і щоразу створюєте схему заново. Але щойно ми дорослішаємо, хоча б навчально, і вмикаємо Flyway, схема стає окремою історією, і її потрібно підтримувати дисципліновано.

Constraint у міграції

Після переходу на Flyway правило просте: хочете UNIQUE — додайте його в SQL-міграцію. Хочете NOT NULL — додайте alter column ... set not null. Хочете FK — додайте foreign key (...) references ....

Так, в entity теж можна написати nullable=false і unique=true. І так, це корисно. Але проєкт тримається на міграціях.

Умовний приклад окремої міграції (фрагмент; не обовʼязково виділяти все в один файл, але в навчальному проєкті так часто простіше):

-- V6__catalog_constraints.sql
alter table category
    add constraint uk_category_code unique (code);

alter table product
    add constraint uk_product_sku unique (sku);

І окремий шматок для FK:

-- V7__ordering_constraints.sql
alter table order_item
    add constraint fk_order_item_order
    foreign key (order_id) references customer_order(id);

Це не єдиний стиль. Важливо, щоб у репозиторії можна було відкрити db/migration і побачити: ось у нас унікальність, ось обовʼязковість, ось звʼязність.

ddl-auto=validate

Оскільки ми не хочемо, щоб Hibernate намагався сам створювати схему, а Flyway уже робить це як джерело правди, розумно налаштувати JPA так, щоб він перевіряв відповідність мапінгу й схеми. Це допомагає ловити ситуації на кшталт «у коді nullable=false, а в схемі все ще nullable».

Фрагмент application.yml для dev-профілю:

spring:
  jpa:
    hibernate:
      # Hibernate не створює і не змінює схему, а лише перевіряє її відповідність entity
      ddl-auto: validate

У такому режимі застосунок не стартує, якщо схема не відповідає мапінгу. Це іноді дратує, особливо коли ви щось швидко змінюєте, але в навчальному проєкті це чудовий спосіб не накопичити розсинхронізацію між кодом і БД.

Чек-лист на зміни схеми

Є простий чек-лист, який я раджу тримати в голові щоразу, коли ви додаєте або змінюєте поле чи звʼязок:

— Якщо поле обовʼязкове в домені, то в entity має бути nullable=false і в міграції має бути NOT NULL.

— Якщо поле унікальне за змістом, то в entity добре б позначити унікальність, але в міграції обовʼязково має зʼявитися UNIQUE.

— Якщо обʼєкт посилається на інший обʼєкт, то в таблиці має бути FK, а індекс на FK-колонці часто теж потрібен, але індекси ми сьогодні не розгортаємо.

Це не магія. Це просто здоровий глузд + дисципліна.

6. Типові помилки при обмеженнях

На обмеженнях схеми найчастіше ламаються не тому, що вони складні, а тому, що розробник намагається змусити їх поводитися як Java-код: «ну я ж перевірив, навіщо база свариться». База свариться, бо вона — остання лінія оборони, і їй байдуже, що ви там перевіряли пʼять мілісекунд тому. Нижче — кілька помилок, які я бачив у навчальних і реальних проєктах занадто багато разів (і так, я сам теж так робив, коли був молодий і хотів жити без міграцій).

Помилка №1: плутати nullable=false в entity із NOT NULL у схемі.
Іноді розробник ставить @Column(nullable = false) і щиро вважає, що база тепер точно не прийме NULL. Але якщо схемою керує Flyway, то анотація сама по собі схему не змінює. У результаті в таблицю все ще можна записати NULL через інший шлях, а потім дивуватися, чому сервіс раптово ловить NullPointerException під час читання. Правильний підхід — зробити NOT NULL у міграції й тримати анотацію як відображення наміру.

Помилка №2: сподіватися на pre-check і не ставити UNIQUE («ми ж перевірили existsBy…»).
Перевірка через existsBySku(...) корисна, але вона не захищає від гонок. Два паралельні запити можуть пройти перевірку й обидва спробувати вставити рядок. Лише UNIQUE на рівні бази робить унікальність справжньою гарантією. Тому правильна схема — pre-check для зрозумілої поведінки + constraint для залізної цілісності.

Помилка №3: робити FK nullable там, де звʼязок обовʼязковий.
Це класика: у Java написали «товар зобовʼязаний мати категорію», а в SQL забули NOT NULL для category_id. У результаті зʼявляються товари з NULL у FK-колонці, і далі весь код починає обростати перевірками на кшталт «якщо категорія є…». На цьому місці модель перетворюється на кашу. Якщо звʼязок обовʼязковий — робіть NOT NULL і FK.

Помилка №4: вмикати ON DELETE CASCADE «щоб не заважало видаляти».
Бажання зрозуміле: ви пробуєте видалити категорію, база не дає, і здається, що constraint заважає жити. Але CASCADE — це не виправлення помилки, а зміна бізнес-семантики: тепер видалення категорії означатиме масове видалення товарів. Це може бути правильно, але має бути усвідомленим. У навчальному mini-shop частіше безпечніше забороняти видалення й реалізувати акуратний use case: деактивувати, перенести, очистити звʼязки.

Помилка №5: не давати constraint-ам імен і потім страждати під час діагностики.
Коли constraint не названо явно, PostgreSQL придумає імʼя сам. Воно буде коректним, але часто не найзручнішим. У момент, коли ви ловите помилку, вам буде простіше побачити uk_product_sku, ніж розшифровувати автозгенероване імʼя. Це дрібниця, яка раптово економить час і нерви, особливо коли помилок стає більше ніж одна.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ