Как на уровне схемы гарантировать, что платёж связан ровно с одним типом документа: заказом или возвратом?
Нужно использовать два nullable-внешних ключа и ограничение CHECK, требующее, чтобы ровно один из них был заполнен. Каждый внешний ключ отдельно гарантирует существование соответствующего заказа или возврата.
Проверка должна явно учитывать NULL: условие «заполнен заказ или возврат, но не оба» задаётся как исключающее «или», а не простым сравнением значений.
Реляционная модель отделяет идентификацию сущности от ссылок на связанные сущности. Когда одна запись может ссылаться на один из нескольких типов объектов, разработчики часто используют полиморфную пару «тип объекта — идентификатор», но такая пара обычно не позволяет задать обычный внешний ключ.
Раздельные внешние ключи с ограничением взаимоисключения появились как практический способ сохранить ссылочную целостность для небольшого фиксированного набора типов документов. Схема сама контролирует допустимые связи, а не оставляет это только прикладному коду.
Пусть платёж должен относиться либо к заказу, либо к возврату. Если хранить два nullable-поля без дополнительного ограничения, возможны две ошибки: платёж не связан ни с одним документом или одновременно связан с обоими.
Если вместо внешних ключей хранить поля тип_документа и идентификатор, база данных не сможет обычным ссылочным ограничением проверить, что идентификатор существует именно в нужной таблице. Ошибка может проявиться только при чтении или обработке платежа.
Для каждого допустимого типа документа создают отдельный внешний ключ. Затем добавляют CHECK, допускающий ровно два состояния: заказ заполнен, возврат отсутствует либо заказ отсутствует, возврат заполнен.
Проверка использует IS NULL и IS NOT NULL, поэтому не зависит от трёхзначной логики SQL. Простое выражение вроде order_id <> refund_id здесь не подходит: идентификаторы принадлежат разным таблицам, могут совпадать численно, а сравнение с NULL даёт UNKNOWN.
Nullable-внешний ключ сам по себе не требует наличия родительской строки: NULL означает отсутствие ссылки. Именно CHECK запрещает состояние без ссылки, а внешний ключ проверяет существование выбранного родителя. Если понадобится поддержать третий тип документа, придётся добавить ещё один внешний ключ и расширить условие.
Есть важный компромисс. Два внешних ключа хорошо подходят для небольшого стабильного набора типов и дают явную ссылочную целостность, но изменение списка типов требует изменения схемы. Универсальная таблица-родитель с единым идентификатором проще расширяется, однако требует дополнительного моделирования общего типа документа и может усложнить жизненный цикл данных.
В платёжном сервисе сначала использовали поля document_type и document_id. Это позволяло легко добавлять новые типы документов, но база не могла гарантировать существование цели ссылки: часть платежей указывала на удалённые или никогда не существовавшие записи.
Рассматривались два варианта. Полиморфная ссылка сохраняла простую структуру, но перекладывала проверки на приложение и усложняла каскадное удаление. Два внешних ключа с CHECK обеспечивали строгую целостность, но добавление нового типа требовало миграции.
Поскольку типов было всего два и они менялись редко, выбрали второй вариант. В результате некорректные платежи стали отбрасываться на границе базы данных, а запросы и аудит получили явные связи с конкретными таблицами.
Что произойдёт, если оба внешних ключа сделать обязательными?
Такая схема потребует одновременно существования заказа и возврата, то есть выразит логическое «И», а не «ровно один». Для взаимоисключающей связи оба поля должны допускать NULL, а правило выбора задаётся отдельным CHECK.
Почему недостаточно проверять это правило только в приложении?
Данные могут поступать через несколько сервисов, административные операции, загрузки и прямые SQL-запросы. Приложение также может выполнить проверку и запись неатомарно. Ограничение базы действует для всех способов изменения данных и предотвращает некорректное состояние непосредственно при вставке или обновлении.
Когда лучше выбрать общую таблицу-родитель вместо двух внешних ключей?
Общая таблица оправдана, если типов документов много, они часто добавляются или у них есть общий жизненный цикл. Тогда платёж ссылается на единую сущность документа, а конкретная таблица-задача расширяет её по модели «один-к-одному». Цена решения — дополнительные таблицы, операции и необходимость аккуратно гарантировать соответствие общего документа ровно одному специализированному типу.