Constraints как часть модели: domain, key, referential, check
Модель данных — это не только список таблиц и столбцов. Это ещё и набор правил, которым данные обязаны подчиняться. «Возраст не может быть отрицательным», «у каждого заказа есть существующий клиент», «email уникален» — всё это часть модели, ничуть не меньшая, чем сами таблицы. В реляционной модели эти правила называются constraints (ограничения целостности), и они формулируются прямо в схеме базы данных.
Ключевая идея урока: constraint, объявленный в схеме, проверяется самой СУБД при каждой записи. Это принципиально надёжнее, чем проверка в коде приложения. Приложений может быть несколько (веб, мобильное, скрипт миграции, ручной SQL аналитика), каждое может содержать баг или просто забыть проверку. СУБД — одна, и она проверяет правило для всех и всегда. Constraint в схеме — это инвариант, который невозможно обойти.
Codd выделял несколько видов ограничений. Разберём четыре, с которыми junior-инженер работает каждый день.
Domain constraint: какие значения допустимы
В первом уроке модуля мы говорили: domain — это множество допустимых значений атрибута. Domain constraint — это и есть правило, ограничивающее столбец только значениями из его domain.
Самый базовый уровень domain constraint — тип данных. Объявив столбец age INTEGER, вы уже запретили в него строку 'hello'. Но тип — грубое приближение: INTEGER разрешает и -5, и 999999, хотя возраст человека такими не бывает.
Domain доуточняют двумя средствами:
NOT NULL— запрещает отсутствие значения (мы подробно разбирали в уроке про NULL).CHECK— задаёт произвольный предикат (о нём ниже, это отдельный пункт; CHECK часто и используют именно для уточнения domain).
Некоторые СУБД поддерживают и явный тип DOMAIN или ENUM:
-- PostgreSQL: domain как именованный тип
CREATE DOMAIN currency_code AS CHAR(3)
CHECK (VALUE IN ('USD', 'EUR', 'RUB', 'JPY'));
CREATE TABLE prices (
price_id INTEGER PRIMARY KEY,
amount NUMERIC(12,2) NOT NULL,
currency currency_code NOT NULL -- допустимы только 4 кода валют
);
Domain constraint отвечает на вопрос «какое значение в принципе может стоять в этой ячейке». Это первая линия защиты данных.
Key constraint: уникальность строк
Key constraint гарантирует, что значения в столбце (или наборе столбцов) уникальны — двух строк с одинаковым значением ключа быть не может. Это прямая реализация свойства relation «нет дубликатов строк», о котором говорил второй урок модуля.
Два инструмента в SQL:
PRIMARY KEY— главный ключ таблицы. Уникален И автоматическиNOT NULL. Таблица может иметь только один PRIMARY KEY.UNIQUE— тоже гарантирует уникальность, но допускает NULL (в большинстве СУБД), и таких ограничений на таблице может быть несколько.
CREATE TABLE users (
user_id INTEGER PRIMARY KEY, -- главный ключ: уникален + NOT NULL
email TEXT NOT NULL UNIQUE, -- email тоже уникален (альтернативный ключ)
phone TEXT UNIQUE -- телефон уникален, но может быть NULL
);
INSERT INTO users VALUES (1, '[email protected]', '+100');
INSERT INTO users VALUES (2, '[email protected]', '+200');
-- ERROR: duplicate key value violates unique constraint
-- СУБД отклонила вставку: email '[email protected]' уже занят
Без key constraint таблица перестаёт быть relation и превращается в «мешок» строк (это мы разбирали). Поэтому key constraint — не опция, а условие реляционности. Всему виду ключей — superkey, candidate key, composite key — посвящён следующий модуль курса; здесь важно зафиксировать: ключ — это constraint, который СУБД проверяет на каждой вставке.
Referential constraint: целостность связей
Referential constraint (он же foreign key, FK) — это правило, связывающее две таблицы. Оно гарантирует: значение в столбце-ссылке одной таблицы обязано существовать как ключ в другой таблице. Это и называется referential integrity (ссылочная целостность).
Классический пример: таблица orders ссылается на users. Не должно существовать заказа, привязанного к несуществующему пользователю — иначе это «осиротевшая» (orphan) строка, мусор, который ломает любую аналитику.
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id), -- foreign key
amount NUMERIC(12,2) NOT NULL
);
INSERT INTO users VALUES (1, 'Ann');
INSERT INTO orders VALUES (101, 1, 250.00); -- OK: пользователь 1 существует
INSERT INTO orders VALUES (102, 99, 70.00); -- ERROR: пользователя 99 нет
-- ERROR: insert violates foreign key constraint
Что СУБД физически делает при вставке заказа с FK: она выполняет поиск — есть ли в users строка с таким user_id. Если нет — INSERT отклоняется. Этот поиск стоит денег, и чтобы он был быстрым, столбец FK почти всегда нужно проиндексировать. Без индекса каждая проверка FK — полное сканирование родительской таблицы.
Referential constraint работает в обе стороны. При удалении строки из users СУБД проверяет: не ссылается ли на неё кто-то из orders? Поведение задаётся опциями ON DELETE / ON UPDATE:
| Опция | Что происходит при удалении родительской строки |
|---|---|
ON DELETE RESTRICT (по умолчанию) | удаление запрещается, если есть ссылающиеся строки |
ON DELETE CASCADE | вместе с родителем удаляются все ссылающиеся строки |
ON DELETE SET NULL | в ссылающихся строках FK становится NULL |
-- При удалении пользователя удалить и все его заказы:
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
user_id INTEGER REFERENCES users(user_id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL
);
CASCADE удобен, но опасен: одно DELETE FROM users WHERE user_id = 1 может молча стереть тысячи заказов. Выбор опции ON DELETE — это решение модели, и его надо принимать осознанно.
В больших аналитических warehouse foreign keys часто объявляют как NOT ENFORCED (декларативно, без проверки) или не объявляют вовсе: проверка FK на каждой загрузке миллиардов строк слишком дорога, а данные уже очищены на предыдущем слое. Но в OLTP-базе, которая принимает запись от пользователей, FK обязателен — там это единственная защита от осиротевших строк.
Check constraint: произвольное бизнес-правило
Check constraint задаёт произвольный логический предикат, которому обязана удовлетворять каждая строка. Это самый гибкий вид constraint: в нём можно выразить почти любое правило об одной строке.
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(12,2) NOT NULL CHECK (price >= 0),
discount NUMERIC(12,2) CHECK (discount >= 0 AND discount <= price),
status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived'))
);
INSERT INTO products VALUES (1, 'Book', -10, 0, 'active');
-- ERROR: violates check constraint -- price не может быть отрицательной
INSERT INTO products VALUES (2, 'Pen', 100, 150, 'active');
-- ERROR: violates check constraint -- скидка больше цены
CHECK позволяет закодировать в схеме то, что иначе жило бы только в коде приложения: «цена неотрицательна», «дата окончания не раньше даты начала», «скидка не превышает цену». Каждое такое правило, перенесённое в CHECK, — это правило, которое уже невозможно нарушить ни одним приложением.
Ограничение CHECK: стандартный CHECK проверяет предикат в пределах одной строки. Правило «сумма заказа равна сумме его позиций» затрагивает несколько строк из двух таблиц — обычный CHECK так не умеет; для межтабличных и межстрочных правил используют триггеры или проверки в приложении. Но всё, что касается одной строки, должно жить в CHECK.
Тонкость с NULL: CHECK считается не нарушенным, если предикат дал UNKNOWN (то есть если в нём участвовал NULL). CHECK (price >= 0) пропустит строку с price = NULL. Если значение обязательно — добавляйте отдельно NOT NULL.
Почему constraints — часть модели, а не «настройка БД»
Главная мысль урока. Начинающие воспринимают constraints как технические «галочки» при создании таблицы. Это неверно. Constraint — это записанное знание о предметной области.
«Возраст неотрицателен», «у заказа есть клиент», «email уникален» — это факты о том, как устроен реальный мир, который моделирует база. Если эти факты не записаны в схеме, они существуют только в голове разработчика и в коде приложения — то есть могут быть нарушены: новым приложением, миграцией, ручным UPDATE, багом. Constraint переносит факт из «договорённости» в «инвариант, проверяемый машиной».
Поэтому модель данных без constraints — неполная модель. Она описывает структуру (какие таблицы), но не описывает правила (каким данные обязаны быть). Грамотное проектирование — это всегда таблицы плюс constraints вместе.
PRIMARY KEY, FOREIGN KEY, CHECK, UNIQUE в PostgreSQL ACID: как СУБД гарантирует constraints при конкурентных измененияхПопробуй сам
Спроектируйте и создайте в PostgreSQL или SQLite две связанные таблицы — authors(author_id, name, birth_year) и books(book_id, author_id, title, price, pages) — и заложите в схему как можно больше правил предметной области:
- Key:
PRIMARY KEYв обеих таблицах; сделайтеnameавтораUNIQUE. - Referential:
books.author_id— foreign key наauthors. Выберите осознанно опциюON DELETE: что должно случиться с книгами при удалении автора — запрет, каскад или SET NULL? Обоснуйте выбор. - Check:
price > 0,pages > 0,birth_yearв разумном диапазоне (например, от 1400 до 2026). - Domain: где уместно, добавьте
NOT NULL. - Проверьте каждый constraint «на излом»: вставьте книгу с несуществующим
author_id, с отрицательной ценой, с нулевыми страницами, дубликат имени автора. Убедитесь, что СУБД отклоняет каждую некорректную вставку. Это и есть ощущение того, что модель защищает сама себя.