Зачем валидировать на уровне БД
«Это же проверит код на бэкенде, зачем дублировать в базе?» — типичный вопрос. Ответ: код бэкенда — не последняя линия обороны. Базу читают и пишут:
- Несколько микросервисов, написанных разными командами.
- Скрипты миграции данных, которые обходят application layer.
- Аналитики через psql и DBeaver.
- Бывают и баги — забыли валидацию, пропустили проверку в новом эндпоинте.
CHECK-ограничения — это декларативная валидация, гарантированная самой СУБД. Никакой код её не обойдёт, кроме явного ALTER TABLE ... DROP CONSTRAINT. Это и есть «последняя линия обороны».
CHECK — синтаксис и места применения
CHECK (предикат) можно навесить:
- На колонку — обычно при
CREATE TABLEилиALTER TABLE ADD COLUMN. - На таблицу — отдельным
CONSTRAINTпосле колонок, может ссылаться на несколько столбцов.
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL CHECK (length(name) > 0),
price_cents INT NOT NULL CHECK (price_cents > 0),
in_stock INT NOT NULL DEFAULT 0 CHECK (in_stock >= 0),
CONSTRAINT sku_format CHECK (sku ~ '^[A-Z]{2}-[0-9]{3}$')
);
Первые три CHECK — на колонках. Последний — табличный, и в нём
Условие может использовать
now(), current_user, обращений к другим таблицам. PostgreSQL хочет гарантировать, что условие всегда даёт одинаковый ответ для одной и той же строки.
На каждом INSERT и UPDATE строки. Если предикат не TRUE — операция откатывается.
Создаём таблицу с CHECK и пробуем нарушить:
Третий INSERT получит ошибку: new row for relation "inventory" violates check constraint "inventory_qty_check". Транзакция откатится.
CHECK и NULL — снова трёхзначная логика
CHECK пропускает строку, если предикат не FALSE. То есть TRUE и NULL оба считаются «прошедшими». Это сделано специально: иначе колонки с пропусками было бы невозможно проектировать.
CHECK (price_cents > 0)
Эта проверка не отвергнет строку с price_cents = NULL. Чтобы запретить и NULL, и неположительные значения, нужно:
price_cents INT NOT NULL CHECK (price_cents > 0)
NOT NULL отвечает за NULL, CHECK — за остальное. Это разные сущности.
CHECK не запрещает NULL — обрати внимание:
UPDATE и CHECK: что происходит при «провале»
Если UPDATE нарушает CHECK на одной из строк, вся UPDATE-операция откатывается. Не только эта строка — а вся транзакция, в которой шёл UPDATE.
UPDATE inventory SET qty = qty - 100; -- если у какой-то строки qty станет < 0, весь UPDATE откатывается
Это сильное гарантирование атомарности. Если хочется «обновить только те строки, для которых это безопасно» — нужно явно фильтровать в WHERE:
UPDATE inventory SET qty = qty - 100 WHERE qty >= 100;
UPDATE падает целиком из-за одной плохой строки:
После ошибки таблица не изменилась — это и есть атомарность.
ENUM vs CHECK + TEXT — что выбрать
В PostgreSQL есть отдельный тип ENUM (создаётся через CREATE TYPE ... AS ENUM (...)). Альтернатива — TEXT колонка с CHECK (col IN ('a', 'b', 'c')).
ENUM | TEXT + CHECK IN | |
|---|---|---|
| Расход места | 4 байта (oid) | varlena — несколько байт + длина строки |
| Скорость сравнения | быстро (целое) | медленнее (строки) |
| Добавление значения | ALTER TYPE ADD VALUE | ALTER TABLE ... DROP/ADD CONSTRAINT |
| Удаление значения | очень сложно | редактировать список в CHECK |
| Переименование | ALTER TYPE RENAME VALUE | UPDATE данных + CHECK |
| Сортировка | в порядке объявления | алфавитная |
Кратко:
'yes'/'no', 'asc'/'desc'). Для бизнес-перечислений, которые могут эволюционировать, лучше TEXT + CHECK или отдельная справочная таблица с FK.
В нашей схеме orders.status сделан через CHECK — именно по причине гибкости:
status TEXT NOT NULL CHECK (status IN ('pending','paid','shipped','delivered','cancelled','refunded'))
Посмотри, что в нашей таблице orders уже есть CHECK на status:
Второй запрос получит ошибку violates check constraint "orders_status_check".
DOMAIN — типы с встроенной валидацией
DOMAIN — это пользовательский тип, привязанный к существующему, но с дополнительными ограничениями (CHECK, NOT NULL, DEFAULT). Полезен, когда одно и то же ограничение нужно применить во многих таблицах.
CREATE DOMAIN email_addr AS TEXT
CHECK (VALUE ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$');
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email email_addr NOT NULL UNIQUE
);
Теперь email-валидация регуляркой не дублируется в каждой таблице с email-колонкой — она зашита в сам тип. Поменять регулярку — ALTER DOMAIN, и она вступит в силу везде.
Создай DOMAIN и проверь валидацию:
DOMAIN используется реже, чем заслуживает, — но в проектах с десятком таблиц на похожих типах он сильно экономит время.
NOT VALID — добавить CHECK без проверки старых данных
В больших таблицах добавление CHECK может занять часы — придётся прогнать предикат по всем строкам. PostgreSQL даёт способ обойти это:
ALTER TABLE huge_table ADD CONSTRAINT no_negatives CHECK (amount >= 0) NOT VALID;
NOT VALID говорит: новые строки и UPDATE-ы проверяй, но старые данные не трогай. Потом, в окно низкой нагрузки, можно сделать ALTER TABLE ... VALIDATE CONSTRAINT ... — это запустит проверку существующих строк.
Это критичная опция для миграций на горячих таблицах. Без NOT VALID миграция может заблокировать таблицу на десятки минут.
Чек-лист
CHECK (предикат)— декларативная валидация на уровне БД, обходится толькоDROP CONSTRAINT.- Условие проверяется при INSERT и UPDATE на новой версии строки. При DELETE — нет.
CHECKпропускает строки, где предикатTRUEилиNULL. Чтобы запретить NULL — нуженNOT NULL.- Если UPDATE нарушает CHECK хоть на одной строке — вся операция откатывается.
ENUMхорош для стабильных перечислений;TEXT + CHECK IN (...)— для эволюционирующих.DOMAIN— переиспользуемый тип с встроеннымCHECK. Полезен в проектах с повторяющимися правилами.ALTER TABLE ADD CONSTRAINT ... NOT VALID— добавить CHECK без блокировки старых данных. ПотомVALIDATEв окно низкой нагрузки.