Learning Platform
Глоссарий Troubleshooting
Урок 10.03 · 19 мин
Начальный
constraintscheck-constraintsbusiness-rulesoltp

Constraints как код модели: CHECK, UNIQUE, NOT NULL, exclusion

В прошлом уроке foreign key защитил одно бизнес-правило — «у заказа есть существующий покупатель». Но бизнес-правил гораздо больше: цена не может быть отрицательной, email уникален, дата окончания не раньше даты начала, две брони не могут пересекаться по времени. Все эти правила тоже можно — и нужно — закодировать прямо в схеме базы данных. Инструмент для этого — constraints (ограничения).

Главная мысль урока: constraints — это часть модели данных, а не второстепенная техническая деталь и не «то, что добавим потом». Ограничение в схеме — это формальная запись бизнес-правила, которую СУБД исполняет при каждой операции. В этом уроке мы разберём CHECK, UNIQUE, NOT NULL и exclusion-constraints и покажем, почему валидация бизнес-правил должна жить в модели.


Почему валидация должна быть в модели данных

Возникает резонный вопрос: зачем дублировать проверки в базе, если приложение и так валидирует ввод? Ответ — в надёжности гарантии.

Проверка в коде приложения — это проверка на одном пути. Но к одной базе обычно ведёт несколько путей записи: основное приложение, фоновые задачи, скрипты миграций, ручные правки администратора через SQL-консоль, второй сервис, интеграция. Проверка, написанная только в основном приложении, не действует ни на одном из остальных путей. Достаточно одному пути записать price = -100 — и в базе появятся некорректные данные, а другие пути будут считать их верными.

Constraint в схеме — это проверка в самой точке хранения. Она действует на всех путях записи без исключения: какой бы код ни вставлял строку, СУБД применит ограничение. Это превращает бизнес-правило из «договорённости, которую все обещали соблюдать» в инвариант, который физически не может быть нарушен.

Constraint в схеме защищает все пути записи
ПриложениеОсновной сервис — пишет данные
Фоновые задачиCron, очереди — тоже пишут
Миграции / SQLСкрипты и ручные правки администратора
все пишут в
Constraint в схемеСУБД проверяет правило на КАЖДОЙ записи, с любого пути — нарушить нельзя

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

WARNING

Распространённая ошибка: ‘валидацию сделаем в приложении, база пусть просто хранит’. В результате база допускает заказы с отрицательной суммой, дубли 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.

NOTE

UNIQUE — это место, где нормализация встречается с реализацией. Каждый candidate key, найденный при нормализации через замыкание атрибутов, должен стать либо PRIMARY KEY, либо UNIQUE-ограничением в физической схеме. Так теоретические FD превращаются в правила, которые СУБД реально стережёт.

Полный DDL-синтаксис constraints — NOT NULL, UNIQUE, CHECK, FK в одном месте

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-ограничениями, вы читаете не только структуру, но и правила предметной области.

WARNING

У 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 позволяет сделать его структурной гарантией, а не проверкой в коде, которую легко обойти при конкурентной вставке двух броней одновременно.

Четыре вида ограничений — четыре класса правил
NOT NULLАтрибут обязателен — значение не может отсутствовать
UNIQUEЗначение или комбинация не повторяется — кодирует candidate key
CHECKПроизвольное условие на значения одной строки — цена >= 0, даты в порядке
EXCLUDEЗапрет пересечения строк — брони одной комнаты не накладываются

Сводка: ограничения как описание правил

Соберём всё. Хорошо спроектированная 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’; у одного пользователя не может быть двух подписок с пересекающимися периодами действия.

Выполните на бумаге:

  1. Для каждого бизнес-правила определите, каким ограничением оно выражается (NOT NULL / UNIQUE / CHECK / FOREIGN KEY / EXCLUDE).
  2. Напишите CREATE TABLE с полным набором ограничений. Для status используйте CHECK со списком допустимых значений.
  3. Объясните, почему правило про пересекающиеся периоды нельзя выразить через UNIQUE и почему обычный CHECK тоже не подходит.
  4. Приведите конкретный пример: какой некорректный INSERT каждое из ваших ограничений отклонит, и что было бы с данными, если бы это правило проверялось только в коде приложения, а один из путей записи проверку пропустил.

Проверка знанийKnowledge check
Почему constraints считаются частью модели данных, а не технической деталью, и какой класс бизнес-правил выражает каждый из видов ограничений — NOT NULL, UNIQUE, CHECK, exclusion?
ОтветAnswer
Constraints — часть модели данных, потому что каждое ограничение это формальная запись бизнес-правила, исполняемая самой СУБД. Их нельзя считать второстепенной деталью или 'тем, что добавим потом' по причине надёжности гарантии: проверка в коде приложения действует только на одном пути записи, а к базе обычно ведёт несколько путей — основное приложение, фоновые задачи, миграции, ручные SQL-правки, другие сервисы; правило, проверяемое лишь в одном приложении, не действует на остальных путях, и достаточно одному из них записать некорректные данные. Constraint же действует в самой точке хранения, на всех путях записи без исключения, превращая бизнес-правило из договорённости в инвариант, который физически невозможно нарушить. Поэтому ограничения проектируют вместе со схемой. Каждый вид кодирует свой класс правил: NOT NULL — обязательность атрибута (значение не может отсутствовать); UNIQUE — уникальность значения или комбинации столбцов, что прямо соответствует candidate key из нормализации; CHECK — произвольное логическое условие на значения одной строки (цена не отрицательна, количество положительно, дата окончания позже даты начала); FOREIGN KEY — ссылочную целостность; exclusion constraint — запрет пересечения строк (две брони одной комнаты не накладываются по времени), чего не выразить ни через UNIQUE, который ловит только точное равенство, ни через обычный CHECK, который ограничен одной строкой. Вместе они превращают определение таблицы в исполняемую спецификацию предметной области: модель описывает не только как данные устроены, но и какими они имеют право быть.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Почему валидацию бизнес-правил рекомендуется размещать в виде constraints в схеме базы, а не только в коде приложения?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5