UNIQUE и CHECK (NULLS NOT DISTINCT)
Brew залил новый прайс-лист скриптом, и наутро поддержка завалена жалобами: в меню появились дубли одной позиции, у пары напитков цена 0 (касса отдавала их бесплатно), а у одного — размер XXL, которого нет ни в одном стакане. Импорт при этом отработал «успешно» — БД молча проглотила мусор, потому что схема ничего не запрещала. Всё это лечится не кодом импортёра, а двумя декларативными ограничениями прямо в таблице: UNIQUE против дублей и CHECK против бессмысленных значений.
Цель юнита — два ограничения и одна ловушка. UNIQUE запрещает повтор значения — но с NULL ведёт себя неожиданно: по умолчанию NULL ≠ NULL, поэтому UNIQUE-колонка принимает сколько угодно NULL-строк. PG15 добавил NULLS NOT DISTINCT — режим, в котором NULL = NULL и второй NULL уже дубль. CHECK проверяет само значение (price > 0, size из набора) и отбивает нарушителя с SQLSTATE 23514. Разберём, когда какое поведение NULL тебе нужно.
UNIQUE и коварство NULL
UNIQUE (slot) обещает: двух строк с одинаковым slot не будет. Но NULL в SQL — это «неизвестно», а два неизвестных не равны друг другу (NULL = NULL → unknown, а не true). Это одна грань трёхзначной логики NULL — полностью её разбирает 03-06, здесь же берём только следствие для уникальности. Поэтому стандартный UNIQUE считает все NULL разными и пропускает их сколько угодно. Часто это именно то, что нужно: NULL означает «не задано / не применимо», и таких строк может быть много (десятки напитков без промо-слота). Непустые же значения по-прежнему уникальны: второй 'A' — дубль (SQLSTATE 23505).
UNIQUE NULLS NOT DISTINCT (PG15+) переворачивает это для NULL: теперь NULL считается равным NULL, и второй NULL отбивается тем же 23505. Это нужно, когда NULL — не «много N/A», а одно конкретное состояние, которого должно быть не больше одного (классика — «ровно одна активная запись»). Раньше такой инвариант приходилось городить через partial unique index WHERE col IS NULL; теперь это одна оговорка в объявлении.
CHECK: проверка значения в схеме
CHECK (price > 0) и CHECK (size IN ('small','medium','large')) — это валидация, вшитая в таблицу: любая строка, нарушающая условие, отбивается с check_violation (23514). Импорт с ценой 0 или размером XXL просто не сможет «приземлиться» — независимо от того, какой клиент и какой код пишет в базу. Это и есть смысл декларативных ограничений: правило живёт в одном месте, схеме, а не размазано по всем кодовым путям, которые что-то вставляют.
Два режима UNIQUE
Один и тот же UNIQUE, два поведения для NULL — выбираешь по смыслу пустого значения:
| Ось | UNIQUE (по умолчанию) | UNIQUE NULLS NOT DISTINCT (PG15+) |
|---|---|---|
Сравнение NULL | NULL ≠ NULL (два неизвестных не равны) | NULL = NULL |
Сколько NULL пропустит | сколько угодно | максимум один |
| Непустой дубль | отклонён (23505) | отклонён (23505) |
Смысл NULL | «много N/A»: не задано / не применимо | «ровно одна активная запись» |
| Заменяет | — | старый трюк с partial unique index WHERE col IS NULL |
Что показывает наш код
Две таблицы под два режима UNIQUE и одна под CHECK (DDL в schema.sql):
CREATE TABLE uniq_default (id ..., slot TEXT, UNIQUE (slot)); -- NULL ≠ NULL
CREATE TABLE uniq_nnd (id ..., slot TEXT, UNIQUE NULLS NOT DISTINCT (slot)); -- NULL = NULL
CREATE TABLE check_drink (id ..., name TEXT NOT NULL,
price BIGINT NOT NULL CHECK (price > 0),
size TEXT NOT NULL CHECK (size IN ('small','medium','large')));main.go льёт по два NULL в каждую UNIQUE-таблицу и сравнивает результат, затем пробует дубль непустого значения и три варианта check_drink:
queries.InsertUniqDefaultNull(ctx); queries.InsertUniqDefaultNull(ctx) // оба пройдут → строк = 2
queries.InsertUniqNNDNull(ctx); queries.InsertUniqNNDNull(ctx) // второй → 23505 → строк = 1
queries.InsertCheckDrink(ctx, ...Price: 0 ...) // 23514
queries.InsertCheckDrink(ctx, ...Size: "huge" ...) // 23514Ошибки печатаются как SQLSTATE — код детерминирован, в отличие от текста сообщения.
Запуск
docker compose up -d
make lecture L=02-schema-and-constraints/02-04-unique-and-check T=db-reset
make lecture L=02-schema-and-constraints/02-04-unique-and-checkВывод:
1) UNIQUE по умолчанию: NULL ≠ NULL (NULLs distinct)
две строки slot = NULL → обе приняты: строк = 2
дубль непустого slot = 'A' → отклонён: SQLSTATE 23505 (unique_violation)
2) UNIQUE NULLS NOT DISTINCT (PG15+): NULL = NULL
две строки slot = NULL → вторая отклонена: SQLSTATE 23505; строк = 1
3) CHECK (price > 0; size IN ('small','medium','large')):
price = 0, size = 'small' → отклонён: SQLSTATE 23514 (check_violation)
price = 300, size = 'huge' → отклонён: SQLSTATE 23514 (check_violation)
price = 300, size = 'small' → принятВ первой таблице два NULL-слота легли спокойно (NULL ≠ NULL), а непустой дубль 'A' отбит (23505). Во второй те же два NULL уже считаются одинаковыми — второй стал дублем, осталась одна строка. И CHECK не пустил ни нулевую цену, ни размер вне набора (оба 23514), а корректную позицию принял. Тот ночной импорт с такими ограничениями просто упал бы на плохих строках, а не насыпал мусор в меню.
Заборчик
Что мы спрятали под NOT NULL: CHECK пропускает NULL. Условие нарушено, только если оно вычислилось в false; NULL даёт unknown, а unknown ≠ false — значит, строка с price = NULL прошла бы CHECK (price > 0) насквозь. Именно поэтому в check_drink колонки помечены NOT NULL: без этого CHECK ловит «-5», но молча пропускает «ничего».
- Пару
NOT NULLрядом сCHECKв проде держат вместе осознанно — иначе проверка дырявая на пустых значениях. - Добавить
CHECK/UNIQUEна большую живую таблицу — это сканирование с блокировкой, и за этим следит твой DBA: констрейнт сначала вешаютNOT VALID, а потом валидируют отдельно (см. 02-06). - Сложную бизнес-валидацию («скидка не больше суммы заказа») не всегда стоит загонять в
CHECK— её сложнее эволюционировать, чем код, и она не видит другие таблицы (для «между строками» нужен EXCLUDE-констрейнт или триггер).
Правило: UNIQUE/CHECK/NOT NULL — для инвариантов уровня одной строки и колонки; они дёшевы, декларативны и неотключаемы — пользуйся ими по умолчанию.
Что забрать с собой
UNIQUEзапрещает дубли, но по умолчаниюNULL ≠ NULL→ несколькоNULLпроходят (это «много N/A»); непустой дубль →SQLSTATE 23505.UNIQUE NULLS NOT DISTINCT(PG15+) делаетNULL = NULL→ допускает максимум одинNULL(это «ровно одна активная запись»); заменяет старый трюк с partial unique index.CHECKвалидирует значение в схеме (price > 0,size IN (...)) → нарушитель отбивается с23514.CHECKпропускаетNULL(условие неfalse, аunknown) — поэтому держиNOT NULLрядом сCHECK.
Дальше — юнит 02-05 «Генерируемые столбцы и домены (PG18 virtual vs stored)»: значение, которое БД вычисляет сама из других колонок (STORED на диске против PG18 VIRTUAL на лету), и DOMAIN — переиспользуемый тип с встроенным CHECK. Это escape-hatch-юнит: VIRTUAL — настолько новая фича, что её ещё не понимает кодоген, поэтому ведём урок psql-скриптом.