Constraints как код модели: CHECK, UNIQUE, NOT NULL, exclusion
В прошлом уроке foreign key защитил одно бизнес-правило — «у заказа есть существующий покупатель». Но бизнес-правил гораздо больше: цена не может быть отрицательной, email уникален, дата окончания не раньше даты начала, две брони не могут пересекаться по времени. Все эти правила тоже можно — и нужно — закодировать прямо в схеме базы данных. Инструмент для этого — constraints (ограничения).
Главная мысль урока: constraints — это часть модели данных, а не второстепенная техническая деталь и не «то, что добавим потом». Ограничение в схеме — это формальная запись бизнес-правила, которую СУБД исполняет при каждой операции. В этом уроке мы разберём CHECK, UNIQUE, NOT NULL и exclusion-constraints и покажем, почему валидация бизнес-правил должна жить в модели.
Почему валидация должна быть в модели данных
Возникает резонный вопрос: зачем дублировать проверки в базе, если приложение и так валидирует ввод? Ответ — в надёжности гарантии.
Проверка в коде приложения — это проверка на одном пути. Но к одной базе обычно ведёт несколько путей записи: основное приложение, фоновые задачи, скрипты миграций, ручные правки администратора через SQL-консоль, второй сервис, интеграция. Проверка, написанная только в основном приложении, не действует ни на одном из остальных путей. Достаточно одному пути записать price = -100 — и в базе появятся некорректные данные, а другие пути будут считать их верными.
Constraint в схеме — это проверка в самой точке хранения. Она действует на всех путях записи без исключения: какой бы код ни вставлял строку, СУБД применит ограничение. Это превращает бизнес-правило из «договорённости, которую все обещали соблюдать» в инвариант, который физически не может быть нарушен.
Поэтому ограничения проектируют вместе со схемой, на этапе моделирования, а не прикручивают «когда дойдут руки». Каждое бизнес-правило, которое можно выразить ограничением, должно стать ограничением.
Распространённая ошибка: ‘валидацию сделаем в приложении, база пусть просто хранит’. В результате база допускает заказы с отрицательной суммой, дубли email, даты-перевёртыши — потому что какой-то путь записи проверку обошёл. Constraints в схеме — это не дублирование, а единственное место, где правило действует для ВСЕХ. Приложение может валидировать дополнительно (ради удобных сообщений об ошибках), но последний рубеж — всегда constraint.
NOT NULL: атрибут обязателен
Самое простое ограничение — NOT NULL. Оно объявляет, что столбец обязан содержать значение; вставка или обновление с NULL в этом столбце отклоняется.
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
name TEXT NOT NULL, -- имя обязательно
email TEXT NOT NULL, -- email обязателен
phone TEXT -- телефон необязателен — NULL допустим
);
NOT NULL — это моделирующее решение: оно отвечает на вопрос «является ли этот атрибут обязательным для сущности». Имя покупателя обязательно — NOT NULL. Телефон может отсутствовать — NULL разрешён. Решение принимается из предметной области, а не «на всякий случай».
Почему это важно для модели: NULL означает «значение отсутствует/неизвестно» и ломает интуицию в сравнениях (сравнение с NULL даёт UNKNOWN, а не TRUE/FALSE). Каждый столбец, который обязан иметь значение, но не помечен NOT NULL, — это потенциальная дыра: где-то появится строка с пропуском, и запросы поведут себя неожиданно.
UNIQUE: значение не повторяется
UNIQUE гарантирует, что значение столбца (или комбинации столбцов) не повторяется в таблице. Это прямая кодировка бизнес-правила «такой атрибут уникален».
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
email TEXT NOT NULL UNIQUE, -- два покупателя не могут иметь один email
name TEXT NOT NULL
);
С UNIQUE на email попытка вставить второго покупателя с уже существующим email будет отклонена. Это и есть защита бизнес-правила «email однозначно идентифицирует покупателя» — без ограничения два пользователя с одним email появятся незаметно, и логика входа в систему сломается.
Несколько важных деталей. UNIQUE может быть составным — уникальна комбинация столбцов: UNIQUE(order_id, product_id) запретит две строки одного товара в одном заказе. UNIQUE отличается от PRIMARY KEY: первичный ключ — это UNIQUE плюс NOT NULL плюс «выбран главным идентификатором»; UNIQUE-столбцов в таблице может быть несколько (это alternate keys), а первичный ключ — один. И UNIQUE связан с замыканием атрибутов из модуля про нормализацию: объявить UNIQUE(X) — значит заявить функциональную зависимость X -> вся строка, то есть что X является candidate key.
UNIQUE — это место, где нормализация встречается с реализацией. Каждый candidate key, найденный при нормализации через замыкание атрибутов, должен стать либо PRIMARY KEY, либо UNIQUE-ограничением в физической схеме. Так теоретические FD превращаются в правила, которые СУБД реально стережёт.
CHECK: произвольное условие на значения
CHECK — самое гибкое ограничение: оно задаёт логическое условие, которому обязано удовлетворять значение строки. Если условие даёт FALSE — операция отклоняется. CHECK позволяет закодировать почти любое правило на уровне строки.
CREATE TABLE order_items (
order_id INT NOT NULL,
product_id INT NOT NULL,
qty INT NOT NULL CHECK (qty > 0), -- количество положительно
unit_price NUMERIC NOT NULL CHECK (unit_price >= 0), -- цена не отрицательна
discount NUMERIC NOT NULL DEFAULT 0
CHECK (discount >= 0 AND discount <= unit_price) -- скидка в разумных пределах
);
Разберём, что здесь закодировано. CHECK (qty > 0) — бизнес-правило «нельзя заказать ноль или отрицательное количество». CHECK (unit_price >= 0) — «цена не бывает отрицательной». CHECK (discount >= 0 AND discount <= unit_price) — «скидка неотрицательна и не больше самой цены»; это пример CHECK с несколькими столбцами одной строки — условие связывает discount и unit_price.
CHECK применяется и к датам — частое и важное правило:
CREATE TABLE bookings (
booking_id INT PRIMARY KEY,
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
CHECK (ends_at > starts_at) -- окончание строго позже начала
);
CHECK (ends_at > starts_at) не даст создать бронь, которая заканчивается раньше, чем началась, — типичная ошибка, которую без ограничения легко пропустить.
Ключевая идея: каждый такой CHECK — это бизнес-правило, записанное в модели. Без него правило живёт лишь в голове разработчика и в коде одного приложения; с ним — оно становится свойством самих данных. Перечитывая определение таблицы с CHECK-ограничениями, вы читаете не только структуру, но и правила предметной области.
У CHECK есть граница: в стандартном виде он проверяет одну строку и не может ссылаться на другие строки или другие таблицы (правило ‘не более 5 активных заказов на клиента’ обычным CHECK не выразить — оно про множество строк). Такие межстроковые правила реализуют через UNIQUE/exclusion, триггеры или логику приложения в транзакции. CHECK — для правил уровня строки, и в этих пределах он незаменим.
Exclusion constraints: запрет пересечений
Есть класс бизнес-правил, который не покрывают ни UNIQUE, ни CHECK: запрет на пересечение строк. Классический пример — бронирование переговорной: две брони одной комнаты не должны пересекаться по времени. UNIQUE здесь не помогает — он запрещает только точное совпадение значений, а нам нужно запретить перекрытие интервалов, которые не равны, но накладываются.
Для этого в PostgreSQL есть exclusion constraint. Он обобщает UNIQUE: UNIQUE запрещает строки, где значения равны; exclusion-constraint запрещает строки, где значения находятся в заданном отношении — например, «интервалы пересекаются».
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_bookings (
booking_id INT PRIMARY KEY,
room_id INT NOT NULL,
during TSRANGE NOT NULL, -- интервал времени брони
EXCLUDE USING gist (
room_id WITH =, -- та же комната
during WITH && -- И пересекающийся интервал
)
);
Это ограничение читается так: запретить две строки, у которых room_id равны (WITH =) И интервалы during пересекаются (WITH && — оператор пересечения диапазонов). То есть: одна комната не может быть забронирована на пересекающиеся промежутки. Попытка вставить накладывающуюся бронь будет отклонена самой СУБД.
Это правило — «брони не пересекаются» — типичное и важное, и exclusion-constraint позволяет сделать его структурной гарантией, а не проверкой в коде, которую легко обойти при конкурентной вставке двух броней одновременно.
Сводка: ограничения как описание правил
Соберём всё. Хорошо спроектированная OLTP-таблица — это не только столбцы и типы, но и набор ограничений, в которых записаны бизнес-правила:
| Ограничение | Какой класс правил кодирует | Пример |
|---|---|---|
| NOT NULL | Атрибут обязателен | имя покупателя должно быть указано |
| UNIQUE | Значение уникально (candidate key) | email не повторяется |
| CHECK | Условие на значения строки | qty > 0; ends_at > starts_at |
| FOREIGN KEY | Ссылочная целостность | заказ ссылается на существующего покупателя |
| EXCLUDE | Запрет пересечения строк | брони одной комнаты не накладываются |
Глядя на CREATE TABLE с полным набором ограничений, читатель видит не просто хранилище, а исполняемую спецификацию предметной области. Это и есть смысл фразы «constraints — код модели»: модель данных описывает не только то, как данные устроены, но и то, какими они имеют право быть. Валидация бизнес-правил — не довесок к схеме, а её неотъемлемая часть, которую проектируют сразу.
Попробуй сам
Дана задача: спроектировать таблицу подписок на сервис.
SUBSCRIPTIONS(subscription_id, user_id, plan, price, starts_on, ends_on, status)
Известные бизнес-правила: у подписки обязательно есть пользователь и план; цена не может быть отрицательной; дата окончания строго позже даты начала; status принимает только значения ‘active’, ‘paused’, ‘cancelled’; у одного пользователя не может быть двух подписок с пересекающимися периодами действия.
Выполните на бумаге:
- Для каждого бизнес-правила определите, каким ограничением оно выражается (NOT NULL / UNIQUE / CHECK / FOREIGN KEY / EXCLUDE).
- Напишите
CREATE TABLEс полным набором ограничений. Дляstatusиспользуйте CHECK со списком допустимых значений. - Объясните, почему правило про пересекающиеся периоды нельзя выразить через UNIQUE и почему обычный CHECK тоже не подходит.
- Приведите конкретный пример: какой некорректный INSERT каждое из ваших ограничений отклонит, и что было бы с данными, если бы это правило проверялось только в коде приложения, а один из путей записи проверку пропустил.