Редкая, но неприятная ловушка SQL: многие думают, что
CHECK constraint требует, чтобы условие всегда было TRUE. Но это не так, CHECK запрещает только FALSE. А результат UNKNOWN — пропускается.Именно поэтому nullable-колонки внутри
CHECK могут вести себя не так, как ожидает разработчик.Допустим, есть таблица товаров:
products(
id,
price,
discount
)
Хотим запретить скидку больше цены:
ALTER TABLE products
ADD CONSTRAINT chk_discount_price
CHECK (discount <= price);
На первый взгляд всё выглядит правильно.
Теперь такой
INSERT действительно не пройдёт:INSERT INTO products(id, price, discount)
VALUES (1, 100, 150);
Потому что проверка:
150 <= 100 даёт FALSE. А CHECK constraint запрещает строки, где выражение возвращает FALSE. Но дальше начинается важный нюанс SQL и трёхзначной логики.Вот такой
INSERT уже может пройти:INSERT INTO products(id, price, discount)
VALUES (2, 100, NULL);
И такой тоже:
INSERT INTO products(id, price, discount)
VALUES (3, NULL, 50);
Многие ожидают, что
CHECK отклонит такие строки. Но SQL работает иначе, если в сравнении участвует NULL, результатом становится не TRUE и не FALSE, а: UNKNOWNТо есть:
150 <= 100 -- FALSE
NULL <= 100 -- UNKNOWN
50 <= NULL -- UNKNOWN
NULL <= NULL -- UNKNOWN
И вот здесь самая важная мысль:
CHECK constraint считает строку валидной, если результат выражения — TRUE или UNKNOWN. Запрещается только явно FALSE.Это поведение связано с SQL three-valued logic — логикой с тремя состояниями: TRUE, FALSE и UNKNOWN. Именно поэтому
CHECK сам по себе НЕ заменяет NOT NULL. Если колонка обязательная — это нужно указывать отдельно.Правильный вариант:
CREATE TABLE products(
id bigint PRIMARY KEY,
price numeric NOT NULL,
discount numeric NOT NULL,
CONSTRAINT chk_discount_price
CHECK (discount <= price)
);
Теперь NULL уже не сможет пройти, потому что NOT NULL сработает раньше
CHECK. Но в реальных системах скидка часто может отсутствовать. То есть NULL — это нормальное состояние: скидки нет.В таком случае
constraint лучше писать явно и читаемо:ALTER TABLE products
ADD CONSTRAINT chk_discount_price
CHECK (
discount IS NULL
OR discount <= price
);
Такой вариант намного понятнее при чтении схемы. Он явно показывает бизнес-логику: либо скидки нет, либо она не больше цены. Но здесь есть ещё один тонкий момент.
Если
price остаётся nullable: price numeric, то выражение: discount <= price снова может вернуть UNKNOWN. Например:discount = 50
price = NULL
Результат проверки:
50 <= NULL, будет UNKNOWN, а строка снова станет валидной.Поэтому если цена обязательна — нужен отдельный NOT NULL:
price numeric NOT NULL
Похожая ситуация встречается с датами. Например:
CHECK (end_date >= start_date)
Разработчик может думать, что
constraint гарантирует корректный диапазон дат. Но если end_date nullable, такой CHECK спокойно пропускает:end_date = NULL
потому что результат сравнения снова UNKNOWN.
И это может быть абсолютно нормальным поведением. Например, если NULL означает: период ещё не завершён. Но если обе даты обязательны, это нужно фиксировать явно:
start_date date NOT NULL,
end_date date NOT NULL,
CHECK (end_date >= start_date)
🔥 Вывод: если в
CHECK участвуют nullable-поля, constraint может пропускать строки из-за UNKNOWN, CHECK не заменяет NOT NULL. Для обязательных значений всегда нужен отдельный NOT NULL constraint.➡️ SQL Ready | #практика