JavaRush /Курсы /Spring Data JPA /PK/

PK/ FK/ UNIQUE/ NOT NULL: защита данных

Spring Data JPA
2 уровень, 1 лекция
Открыта

1. Ограничения в БД как страховка данных

Когда вы впервые видите PRIMARY KEY, FOREIGN KEY, UNIQUE, NOT NULL, легко подумать: «Ну окей, это какие-то правила, чтобы преподаватель был доволен». На практике всё наоборот: это правила, чтобы ваши данные не превратились в кашу, а ваш сервис не стал детективом, который каждое утро расследует, почему у товара нет категории, а у заказа отрицательная сумма.

Представьте реальность бэкенда: данные в базу может писать не только ваш аккуратный код. Ошибки в сервисе случаются. Тестовые скрипты иногда «слегка» кривые. Кто-то может вручную сделать INSERT — «на минутку, быстро поправить». А иногда у вас просто два потока или два пользователя делают одно и то же одновременно. В таком мире проверка «в Java‑коде, перед сохранением» — это хорошо, но недостаточно. База данных должна уметь твёрдо сказать «нет» там, где вы не хотите даже возможности хранить плохое состояние.

Очень полезно держать в голове простую модель:

flowchart TD
  A["Код приложения"] --> B["SQL запрос: INSERT/UPDATE"]
  B --> C{"Ограничения схемы"}
  C -->|всё ок| D["Данные сохранены"]
  C -->|нарушение| E["Ошибка: БД отказала"]

Смысл ограничений не в том, чтобы усложнить вам жизнь. Смысл в том, чтобы «невозможные состояния» были невозможны физически, а не только «по договорённости» между разработчиками. Давайте разберём четыре базовых кирпича этой защиты.

2. PRIMARY KEY: идентичность строки

PRIMARY KEY (первичный ключ) — это способ сказать базе: «Вот этот столбец (или набор столбцов) однозначно идентифицирует строку». В Java мы привыкли: объект — это объект, у него есть «личность» (ссылка в памяти), даже если поля совпадают. В базе всё проще и строже: без ключа строка — просто набор значений, и база не обязана гадать, какую именно строку вы имели в виду.

Технически PRIMARY KEY даёт нам три очень практичные вещи. Во‑первых, уникальность: нельзя создать две строки с одинаковым PK. Во‑вторых, обязательность значения: PRIMARY KEY не может быть NULL. В‑третьих, возможность безопасно ссылаться на строку из других таблиц через FOREIGN KEY. То есть PK — это как паспортный номер для строки: один, уникальный, и без него вы в системе не существуете.

Возьмём таблицу категорий нашего mini‑shop. Категория — справочник, у неё будет технический идентификатор id, а бизнес‑идентификатор code (например, 'BOOKS').

CREATE TABLE category (
    id BIGINT PRIMARY KEY,      -- Технический идентификатор категории (PK)
    code VARCHAR(50) NOT NULL,   -- Бизнес-код категории (пока без UNIQUE)
    name VARCHAR(100) NOT NULL   -- Человекочитаемое имя категории
);

Здесь id — первичный ключ. Даже если две категории случайно получат одинаковые name (люди любят слово «Other»), база всё равно различит строки по id.

Теперь увидим, как база защищает нас от дубля PK. Это важно: ошибка будет не «где-то потом», а сразу при записи.

INSERT INTO category (id, code, name)
VALUES (1, 'BOOKS', 'Books');

-- Дубликат PRIMARY KEY: id=1 уже существует
INSERT INTO category (id, code, name)
VALUES (1, 'GAMES', 'Games');

Вторая вставка должна упасть, потому что id=1 уже занят. И это отлично: база не позволяет вам создать «две разные категории с одним паспортом». Если бы PK не было, вы могли бы случайно создать дубликаты, а потом в коде началась бы классика: «а почему SELECT вернул две строки, если я ожидал одну?».

Отдельно важная мысль для следующих лекций курса: чаще всего PK — это технический (surrogate) ключ вроде BIGINT, а бизнес‑уникальность (например, code или sku) выражается отдельным UNIQUE. То есть PRIMARY KEY отвечает на вопрос «как однозначно найти строку», а бизнес‑поля отвечают на вопрос «какие значения не должны повторяться, потому что так устроен бизнес».

3. UNIQUE: уникальность бизнес‑значений

UNIQUE — это ограничение, которое запрещает повтор значений в столбце (или наборе столбцов). В жизни проекта это почти всегда про бизнес‑идентичность. У категории бывает уникальный code, у товара — уникальный sku, у заказа — уникальный order_number. И да, это не то же самое, что PRIMARY KEY, хотя внешне похоже: и то, и другое «не даёт повторяться».

Ключевое различие простое и практичное. PRIMARY KEY — это «официальная личность строки» внутри таблицы, и она ровно одна (в рамках таблицы). UNIQUE — это любые дополнительные «нельзя повторять», и их может быть несколько. Более того, UNIQUE часто отвечает за удобство и правила домена, а не за внутреннюю механику ссылок. В одном проекте вы можете спокойно иметь id как PK и три поля с UNIQUE: например, sku, barcode, external_id.

Давайте улучшим таблицу категорий: сделаем так, чтобы code был уникальным. Это прямо следует из смысла: код категории — это «ярлык», по которому вы будете искать и интегрироваться, и он не должен повторяться.

CREATE TABLE category (
    id BIGINT PRIMARY KEY,             -- PK: идентичность строки
    code VARCHAR(50) NOT NULL UNIQUE,   -- Бизнес-код: запрещаем дубли
    name VARCHAR(100) NOT NULL          -- Имя обязательно
);

Теперь база становится вашим «охранником от дублей»:

INSERT INTO category (id, code, name)
VALUES (1, 'BOOKS', 'Books');

-- Дубликат UNIQUE по code: 'BOOKS' уже есть
INSERT INTO category (id, code, name)
VALUES (2, 'BOOKS', 'Books again');

Вторая строка нарушает UNIQUE по code. И это прекрасно: иначе у вас появляется два BOOKS, и начинается хаос. Часть кода берёт первую строку, часть — вторую, отчёты расходятся, а баг‑репорты становятся похожи на рассказы Кафки.

Здесь же полезный нюанс — на уровне «не надо углубляться, но хорошо знать». Если вы поставите UNIQUE, но забудете NOT NULL, то можете неожиданно получить несколько строк с NULL в этом поле. В PostgreSQL NULL — это «неизвестно», и несколько таких «неизвестно» не считаются одинаковыми, поэтому несколько NULL могут спокойно существовать под UNIQUE. Именно поэтому в бизнес‑полях часто идёт пара: NOT NULL UNIQUE.

4. NOT NULL: обязательность данных

NOT NULL выглядит самым скучным ограничением из всех. Кажется: «ну да, поле не может быть пустым, и что?». Но именно NOT NULL чаще всего спасает проект от тихих поломок, которые проявляются не в момент записи, а через неделю — где-нибудь в отчёте, в выгрузке, в поиске, в подсчёте суммы. NULL — это как маленькая дырочка в лодке: сначала почти незаметно, а потом внезапно вы гребёте уже руками, потому что всё мокро.

Смысл NOT NULL очень прямой: если значение обязательно по смыслу, база должна требовать его всегда. Например, у категории должно быть имя. Товар должен иметь name. Заказ должен иметь customer_email. Иначе у вас появляются «безымянные сущности», которые сложно показывать, сложно искать и невозможно нормально поддерживать.

Пример: запретим NULL в name категории.

CREATE TABLE category (
    id BIGINT PRIMARY KEY,             -- PK обязателен и уникален
    code VARCHAR(50) NOT NULL UNIQUE,   -- Бизнес-код обязателен и уникален
    name VARCHAR(100) NOT NULL          -- Имя категории обязательно
);

И теперь база защищает вас от «категории-призрака»:

-- NOT NULL нарушение: поле name обязательное
INSERT INTO category (id, code, name)
VALUES (10, 'GAMES', NULL);

Такое должно падать. Это не «база вредная», это база говорит: «Я не могу хранить категорию без имени, потому что это уже не категория, а набор неопределённости».

Отдельная жизненная ситуация, которая часто случается у новичков: «Давайте сделаем поле nullable, потому что “потом заполним”». Потом не заполняется. Или заполняется в половине случаев. Или новый разработчик даже не знает, что «надо было потом». Поэтому правило для учебного проекта (и для многих реальных проектов) простое: если поле обязано быть по смыслу — делайте NOT NULL сразу. Это дисциплина, которая окупается вдвойне: вы меньше отлаживаете и меньше спорите.

5. FOREIGN KEY: целостность ссылок

FOREIGN KEY — это «клей» реляционной модели. Он выражает простой факт: значение в одной таблице должно ссылаться на существующую строку в другой таблице. В Java мы привыкли, что у нас есть ссылка на объект, и она либо указывает на объект, либо null. В базе вместо ссылок — значения (обычно числа), и без FOREIGN KEY база никак не гарантирует, что эти значения вообще имеют смысл.

На примере: товар относится к категории. В таблице product мы храним category_id. Но откуда мы знаем, что category_id = 999 — это реально существующая категория, а не случайная цифра? Ответ: FOREIGN KEY.

CREATE TABLE product (
    id BIGINT PRIMARY KEY,                       -- PK товара
    sku VARCHAR(50) NOT NULL UNIQUE,              -- Бизнес-идентификатор товара
    name VARCHAR(200) NOT NULL,                   -- Название товара обязательно
    category_id BIGINT NOT NULL,                  -- Ссылка на категорию обязательна
    FOREIGN KEY (category_id) REFERENCES category(id) -- FK: категория должна существовать
);

Теперь база защищает нас от «товара без категории в реальности». Например, если в category есть только id=1, то такая вставка должна упасть:

-- FK нарушение: категории с id=999 не существует
INSERT INTO product (id, sku, name, category_id)
VALUES (100, 'SKU-100', 'Some Product', 999);

И это снова хорошая новость. Иначе вы бы получили строку в product, которую невозможно корректно «соединить» (JOIN) с категорией, невозможно нормально показать пользователю и невозможно поддерживать как часть домена.

Ещё одно полезное следствие FOREIGN KEY: база не даст вам случайно удалить «родителя», если у него есть «дети». Если есть товары, которые ссылаются на категорию, удаление категории станет проблемой. По умолчанию PostgreSQL скажет: «Нельзя, на неё ссылаются». И это логично: иначе вы превращаете товары в сирот с «висящей» ссылкой.

-- Попытка удалить категорию, на которую есть ссылки из product
DELETE FROM category
WHERE id = 1;

Если есть product.category_id = 1, такой DELETE должен завершиться ошибкой. Да, иногда бизнес действительно хочет «удалить категорию и все товары», но это уже отдельная бизнес‑политика (и отдельная тема). Сейчас нам важно понять базовый принцип: FOREIGN KEY делает связи реальными, а не «нарисованными на бумаге».

6. Mini‑shop в одном кадре: все ограничения вместе

Когда смотришь на ограничения по одному, кажется, что это отдельные флажки. В реальности они работают как команда: PRIMARY KEY даёт идентичность, UNIQUE фиксирует бизнес‑уникальность, NOT NULL защищает обязательность, а FOREIGN KEY не даёт отношениям распасться. На домене mini‑shop это особенно наглядно: здесь есть справочники (категории), сущности каталога (товары) и операционные данные (заказы и позиции).

Давайте соберём маленький кусочек будущей схемы так, чтобы было видно, кто за что отвечает. Обратите внимание: мы не проектируем всю базу целиком (это будет в лекции 5), мы просто показываем «скелет ограничений» на понятных местах.

CREATE TABLE customer_order (
    id BIGINT PRIMARY KEY,                    -- PK заказа
    order_number VARCHAR(30) NOT NULL UNIQUE,  -- Номер заказа: обязателен и уникален
    customer_email VARCHAR(255) NOT NULL       -- Email клиента обязателен в нашем домене
);

Здесь PRIMARY KEY нужен для внутренней идентичности заказа, UNIQUE на order_number — чтобы нельзя было завести два заказа с одним номером, а NOT NULL на customer_email — потому что заказ «без клиента» в нашем упрощённом домене бессмысленен.

Теперь таблица позиций заказа. Позиция должна ссылаться и на заказ, и на товар. Это два FOREIGN KEY.

CREATE TABLE order_item (
    id BIGINT PRIMARY KEY,                             -- PK позиции заказа
    order_id BIGINT NOT NULL,                          -- Ссылка на заказ обязательна
    product_id BIGINT NOT NULL,                        -- Ссылка на товар обязательна
    quantity INT NOT NULL,                             -- Количество обязательно (правило > 0 обсудим отдельно)
    FOREIGN KEY (order_id) REFERENCES customer_order(id), -- FK: заказ должен существовать
    FOREIGN KEY (product_id) REFERENCES product(id)       -- FK: товар должен существовать
);

Смысл здесь очень земной: нельзя создать позицию, которая не принадлежит никакому заказу (order_id обязателен), и нельзя создать позицию на товар, которого нет (product_id обязателен и должен ссылаться на реальную строку product).

Теперь мысленно проиграем две ошибки, которые база должна отловить сразу.

Первая ошибка: попытались создать заказ без order_number. В Java это может быть «ой, забыли заполнить поле», в базе — прямой запрет.

-- NOT NULL нарушение: order_number обязателен
INSERT INTO customer_order (id, order_number, customer_email)
VALUES (1, NULL, 'alice@example.com');

Вторая ошибка: попытались создать позицию заказа, которая ссылается на несуществующий заказ.

-- FK нарушение: заказа с id=9999 не существует
INSERT INTO order_item (id, order_id, product_id, quantity)
VALUES (10, 9999, 100, 2);

Если заказа id=9999 нет, база обязана сказать: «Нельзя». Иначе вы получаете «кусок заказа», который вообще ни к чему не привязан. Такие строки потом годами живут в базе как «мусорные артефакты», и их очень сложно вычищать.

Чтобы закрепить общую картину, вот мини‑таблица «что защищает каждое ограничение» на языке нашего проекта:

Ограничение Что защищает Пример в mini-shop Что будет без него
PRIMARY KEY идентичность строки category.id, product.id, customer_order.id дубликаты, невозможность корректно ссылаться
UNIQUE бизнес-уникальность category.code, product.sku, customer_order.order_number дубли sku, два заказа с одним номером
NOT NULL обязательность данных product.name, order_item.quantity, customer_email NULL в критичных полях, поломки «потом»
FOREIGN KEY целостность связей product.category_id -> category.id «висячие ссылки», некорректные JOIN, сироты

Идея сегодняшней лекции именно в этом: ограничения — не про красоту DDL, а про то, чтобы схема сама держала домен в нормальном состоянии, даже если код временно «вышел за хлебом и не вернулся».

7. Ошибки ограничений: чтение и смысл

Первые встречи с ошибками базы обычно эмоциональные. Особенно если вы новичок: вы сделали INSERT, а база отвечает чем-то в духе «violates constraint…». В этот момент легко обидеться на PostgreSQL и сказать: «Ну конечно, ничего не работает». Но лучше воспринимать это как очень конкретную подсказку: база точно говорит, какое правило вы нарушили. Если научиться читать такие ошибки, отладка ускоряется в разы.

У PostgreSQL (и вообще у любой нормальной СУБД) есть характерные «подписи» ошибок для каждого типа ограничения. Если нарушили NOT NULL, обычно будет что-то про “null value violates not-null constraint”. Если нарушили UNIQUE, увидите “duplicate key value violates unique constraint”. Если нарушили FOREIGN KEY, будет “violates foreign key constraint”. Смысл один: вы пытались записать данные, которые противоречат правилам схемы.

Полезная привычка на будущее — давать ограничениям понятные имена (например, category_code_uk или product_category_fk). Это особенно хорошо видно в миграциях (Flyway будет позже), но даже сейчас важно понять мотивацию. Когда имя ограничения нормальное, вы смотрите на ошибку и сразу понимаете, что сломалось: «ага, это product_category_fk, значит, проблема в категории товара». Когда имя «какое-то автоматически сгенерированное», вы тратите время хотя бы на то, чтобы понять, о какой таблице вообще речь.

Покажу пример, как выглядит «именованное» ограничение в DDL. Да, это немного больше текста — зато ошибки становятся намного дружелюбнее.

CREATE TABLE category (
    id BIGINT CONSTRAINT category_pk PRIMARY KEY,        -- Явно именуем PRIMARY KEY
    code VARCHAR(50) CONSTRAINT category_code_uk UNIQUE NOT NULL, -- Именуем UNIQUE и запрещаем NULL
    name VARCHAR(100) NOT NULL                           -- Обязательное поле без отдельного имени ограничения
);

Главное, что вы должны вынести: ошибки ограничений — это не «помеха», а ранняя диагностика. Лучше база громко остановит запись на плохих данных, чем тихо сохранит мусор, который потом будет ломать чтение, отчёты и бизнес‑логику в самых неожиданных местах.

8. Типичные ошибки проектирования ограничений

Ошибка №1: путать «id строки» и «бизнес-уникальность».
Очень частая история: студент делает PRIMARY KEY (sku) или PRIMARY KEY (code) и радуется, что «и так уникально». Потом внезапно выясняется, что бизнес‑значение надо менять (SKU переехал в новую систему, код категории решили переименовать). И вот вы уже меняете «паспорт» сущности, а это почти всегда болезненнее, чем менять обычное поле. В учебном проекте лучше привыкать к схеме: id как PK + sku/code/order_number как UNIQUE.

Ошибка №2: делать поле уникальным, но забывать NOT NULL.
Снаружи кажется, что UNIQUE «и так всё запрещает», но на практике можно получить несколько строк с NULL в этом поле, и внезапно ваша «уникальность» перестаёт быть уникальностью в бизнес‑смысле. Для бизнес‑идентификаторов типа sku, code, order_number почти всегда нужна связка NOT NULL UNIQUE, чтобы и отсутствие значения запрещалось, и дубликаты — тоже.

Ошибка №3: хранить ссылку на другую таблицу как «просто число», без FOREIGN KEY.
Начинающий разработчик может подумать: «Ну я же буду руками следить, что category_id правильный». А потом одна ошибка в скрипте, один тестовый INSERT, одна ручная правка — и появляется товар, который ссылается на несуществующую категорию. Без FK база это проглотит, а вы узнаете об этом позже — когда JOIN неожиданно перестанет возвращать строки или когда отчёт «по категориям» покажет странные провалы.

Ошибка №4: делать обязательные связи и поля nullable «на всякий случай».
Иногда желание «быть гибким» приводит к странным моделям: товар может не иметь категории, позиция заказа может не иметь товара, заказ может не иметь email. Потом сервисный код превращается в сериал «проверки на null» и постоянные «а что, если…». Если по смыслу поле обязано быть, лучше честно запретить NULL и в полях, и в FK‑ссылках.

Ошибка №5: воспринимать ограничения как замену бизнес-логики.
Ограничения — это фундамент, но они не умеют выразить всё. Например, quantity > 0 в позиции заказа — это уже не NOT NULL, а отдельное правило. Поэтому не стоит впадать в другую крайность: «пусть база проверяет всё». Правильная картина мира такая: часть правил удобно проверять в коде (понятные сообщения, сценарии), а базовые «невозможные состояния» фиксировать на уровне схемы, чтобы база не хранила мусор.

1
Задача
Spring Data JPA, 2 уровень, 1 лекция
Недоступна
Дубликат бизнес-кода должен завершаться ошибкой
Дубликат бизнес-кода должен завершаться ошибкой
1
Задача
Spring Data JPA, 2 уровень, 1 лекция
Недоступна
Книга не может ссылаться на несуществующего автора
Книга не может ссылаться на несуществующего автора
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ