PostgreSQL CookbookСхемаUNIQUE и CHECK (NULLS NOT DISTINCT)
0 / 63 (0%)

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 = NULLunknown, а не 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+)
Сравнение NULLNULL ≠ NULL (два неизвестных не равны)NULL = NULL
Сколько NULL пропуститсколько угодномаксимум один
Непустой дубльотклонён (23505)отклонён (23505)
Смысл NULL«много N/A»: не задано / не применимо«ровно одна активная запись»
Заменяетстарый трюк с partial unique index WHERE col IS NULL

Что показывает наш код

Две таблицы под два режима UNIQUE и одна под CHECK (DDL в schema.sql):

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:

go
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 — код детерминирован, в отличие от текста сообщения.

Запуск

sh
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

Вывод:

plaintext
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, а unknownfalse — значит, строка с 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-скриптом.

·Модуль 03

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Схема / UNIQUE и CHECK (NULLS NOT DISTINCT)