Как на уровне схемы гарантировать, что платёж связан ровно с одним типом документа: заказом или возвратом?

Как на уровне схемы гарантировать, что платёж связан ровно с одним типом документа: заказом или возвратом?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Нужно использовать два nullable-внешних ключа и ограничение CHECK, требующее, чтобы ровно один из них был заполнен. Каждый внешний ключ отдельно гарантирует существование соответствующего заказа или возврата.

Проверка должна явно учитывать NULL: условие «заполнен заказ или возврат, но не оба» задаётся как исключающее «или», а не простым сравнением значений.

Исторический контекст

Реляционная модель отделяет идентификацию сущности от ссылок на связанные сущности. Когда одна запись может ссылаться на один из нескольких типов объектов, разработчики часто используют полиморфную пару «тип объекта — идентификатор», но такая пара обычно не позволяет задать обычный внешний ключ.

Раздельные внешние ключи с ограничением взаимоисключения появились как практический способ сохранить ссылочную целостность для небольшого фиксированного набора типов документов. Схема сама контролирует допустимые связи, а не оставляет это только прикладному коду.

Постановка проблемы

Пусть платёж должен относиться либо к заказу, либо к возврату. Если хранить два nullable-поля без дополнительного ограничения, возможны две ошибки: платёж не связан ни с одним документом или одновременно связан с обоими.

Если вместо внешних ключей хранить поля тип_документа и идентификатор, база данных не сможет обычным ссылочным ограничением проверить, что идентификатор существует именно в нужной таблице. Ошибка может проявиться только при чтении или обработке платежа.

Подробное решение

Для каждого допустимого типа документа создают отдельный внешний ключ. Затем добавляют CHECK, допускающий ровно два состояния: заказ заполнен, возврат отсутствует либо заказ отсутствует, возврат заполнен.

CREATE TABLE payment ( payment_id BIGINT PRIMARY KEY, order_id BIGINT, refund_id BIGINT, CHECK ( (order_id IS NOT NULL AND refund_id IS NULL) OR (order_id IS NULL AND refund_id IS NOT NULL) ), FOREIGN KEY (order_id) REFERENCES orders(order_id), FOREIGN KEY (refund_id) REFERENCES refunds(refund_id) );

Проверка использует IS NULL и IS NOT NULL, поэтому не зависит от трёхзначной логики SQL. Простое выражение вроде order_id <> refund_id здесь не подходит: идентификаторы принадлежат разным таблицам, могут совпадать численно, а сравнение с NULL даёт UNKNOWN.

Nullable-внешний ключ сам по себе не требует наличия родительской строки: NULL означает отсутствие ссылки. Именно CHECK запрещает состояние без ссылки, а внешний ключ проверяет существование выбранного родителя. Если понадобится поддержать третий тип документа, придётся добавить ещё один внешний ключ и расширить условие.

Есть важный компромисс. Два внешних ключа хорошо подходят для небольшого стабильного набора типов и дают явную ссылочную целостность, но изменение списка типов требует изменения схемы. Универсальная таблица-родитель с единым идентификатором проще расширяется, однако требует дополнительного моделирования общего типа документа и может усложнить жизненный цикл данных.

Ситуация из практики

В платёжном сервисе сначала использовали поля document_type и document_id. Это позволяло легко добавлять новые типы документов, но база не могла гарантировать существование цели ссылки: часть платежей указывала на удалённые или никогда не существовавшие записи.

Рассматривались два варианта. Полиморфная ссылка сохраняла простую структуру, но перекладывала проверки на приложение и усложняла каскадное удаление. Два внешних ключа с CHECK обеспечивали строгую целостность, но добавление нового типа требовало миграции.

Поскольку типов было всего два и они менялись редко, выбрали второй вариант. В результате некорректные платежи стали отбрасываться на границе базы данных, а запросы и аудит получили явные связи с конкретными таблицами.

Что кандидаты часто упускают

  1. Что произойдёт, если оба внешних ключа сделать обязательными?

    Такая схема потребует одновременно существования заказа и возврата, то есть выразит логическое «И», а не «ровно один». Для взаимоисключающей связи оба поля должны допускать NULL, а правило выбора задаётся отдельным CHECK.

  2. Почему недостаточно проверять это правило только в приложении?

    Данные могут поступать через несколько сервисов, административные операции, загрузки и прямые SQL-запросы. Приложение также может выполнить проверку и запись неатомарно. Ограничение базы действует для всех способов изменения данных и предотвращает некорректное состояние непосредственно при вставке или обновлении.

  3. Когда лучше выбрать общую таблицу-родитель вместо двух внешних ключей?

    Общая таблица оправдана, если типов документов много, они часто добавляются или у них есть общий жизненный цикл. Тогда платёж ссылается на единую сущность документа, а конкретная таблица-задача расширяет её по модели «один-к-одному». Цена решения — дополнительные таблицы, операции и необходимость аккуратно гарантировать соответствие общего документа ровно одному специализированному типу.