Страница базы знаний ссылается на задачу. Задача ссылается на другую задачу как дубликат. Страница ссылается на страницу. Задача ссылается на страницу с техническим заданием.
Четыре типа связей, четыре таблицы. Потом появляется модуль helpdesk с обращениями, и таблиц становится девять. Каждая с двумя внешними ключами, своим репозиторием и своим кодом загрузки, отличающимся только именами.
Я посчитал это на бумаге до того, как написал первую, и остановился.
Одна таблица вместо девяти
#[ORM\Entity]
#[ORM\Table(name: 'entity_link')]
#[ORM\UniqueConstraint(columns: ['source_type', 'source_id', 'target_type', 'target_id', 'link_type'])]
#[ORM\Index(columns: ['source_type', 'source_id'])]
#[ORM\Index(columns: ['target_type', 'target_id'])]
class EntityLink
{
#[ORM\Column(enumType: LinkableEntity::class)]
private LinkableEntity $sourceType;
#[ORM\Column(type: UuidType::NAME)]
private Uuid $sourceId;
#[ORM\Column(enumType: LinkableEntity::class)]
private LinkableEntity $targetType;
#[ORM\Column(type: UuidType::NAME)]
private Uuid $targetId;
#[ORM\Column(enumType: EntityLinkType::class)]
private EntityLinkType $linkType;
}Два энума делают конструкцию управляемой. LinkableEntity перечисляет, что вообще можно связывать: задача, страница, проект, пространство, обращение. EntityLinkType — характер связи: относится к, блокирует, дублирует, документирует, порождает.
Строковый тип вместо энума я тоже рассматривал — и отказался. Энум даёт автодополнение, статический анализ и невозможность записать 'isue' вместо 'issue'. Расширяется он одной строкой, а перечисление в коде не даёт списку типов расползтись.
Три индекса вместо одного: уникальный не даёт создать одну и ту же направленную запись дважды, а два обычных обслуживают поиск в обе стороны. Для симметричных типов вроде relates_to сервис сначала приводит концы к каноническому порядку; иначе пары A→B и B→A прошли бы уникальный ключ как разные. Сам ключ из пяти колонок весит прилично, но проверка существования запросом и медленнее, и не защищает от гонки.
Чем платим
Внешних ключей нет. База не знает, что target_id указывает на строку в wm_issue. Удалили задачу — связь осталась висеть в никуда.
Это главная цена. В статьях про обобщённые связи её обычно смягчают.
Второе следствие: универсального джойна к одной целевой таблице больше нет. Условные JOIN или UNION ALL по известным типам написать можно, и PostgreSQL умеет их планировать, но запрос быстро становится громоздким. В прикладном коде я делаю два шага: сначала связи, потом сущности по списку идентификаторов.
Третье: ON DELETE CASCADE не работает, каскад надо писать руками.
Как компенсировать потерю целостности
Три механизма не заменяют внешний ключ, но сокращают окно для висячих связей и регулярно убирают остаток.
Проверка при создании. Сервис не принимает идентификатор на веру. Ниже фрагмент после нормализации концов симметричной связи:
public function link(
LinkableEntity $sourceType, Uuid $sourceId,
LinkableEntity $targetType, Uuid $targetId,
EntityLinkType $type,
): EntityLink {
if ($sourceType === $targetType && $sourceId->equals($targetId)) {
throw new \DomainException('Сущность нельзя связать саму с собой');
}
foreach ([[$sourceType, $sourceId], [$targetType, $targetId]] as [$type_, $id]) {
if (!$this->resolver->exists($type_, $id)) {
throw new \DomainException("Не найдено: {$type_->value} {$id}");
}
}
return $this->repository->create(/* … */);
}Два запроса на создание связи. Связи создают редко, так что цена приемлемая. Целостность они всё равно не гарантируют: сущность можно удалить между проверкой и вставкой. Это осознанная eventual consistency, которую подстраховывает уборка мусора.
Уборка при удалении. Каждый модуль, удаляя свою сущность, обязан позвать уборку связей. Обеспечивается это событием домена, а не памятью разработчика:
#[AsEventListener]
public function onEntityDeleted(EntityDeletedEvent $event): void
{
$this->links->deleteAllFor($event->type, $event->id);
}Уборка мусора. Раз в неделю команда проходит по связям и проверяет, что обе стороны на месте. Находки логируются и удаляются. За год она нашла 34 висячие связи — все после ручных удалений в базе во время отладки.
Загрузка без N+1
Наивный вариант ужасен: получить связи страницы, для каждой сходить в свой репозиторий. Десять связей — одиннадцать запросов.
Резолвер группирует по типу и делает один запрос на тип:
final class EntityLinkResolver
{
/** @return array<string, LinkedEntityView> ключ — "type:id" */
public function resolve(array $links): array
{
$byType = [];
foreach ($links as $link) {
$byType[$link->getTargetType()->value][] = $link->getTargetId();
}
$result = [];
foreach ($byType as $type => $ids) {
$provider = $this->providers[$type]
?? throw new \LogicException("Нет провайдера для {$type}");
foreach ($provider->loadMany($ids) as $id => $view) {
$result["{$type}:{$id}"] = $view;
}
}
return $result;
}
}Провайдер — маленький интерфейс, который каждый модуль реализует для своих сущностей:
interface LinkedEntityProvider
{
/** @return array<string, LinkedEntityView> */
public function loadMany(array $ids): array;
}LinkedEntityView — плоский объект с тем, что нужно для отображения: заголовок, ссылка, иконка, статус. Не сущность. Это существенно: модуль задач отдаёт представление, а не свою внутреннюю модель, и граница остаётся на месте.
Максимум четыре запроса на любую страницу связей, независимо от их количества. Больше типов у меня нет.
Симметричность связи
Вопрос, который я недооценил. Если задача A блокирует задачу B, это одна запись или две?
Я выбрал одну запись и вычисление обратной стороны при чтении. У каждого типа связи есть обратный:
enum EntityLinkType: string
{
case RelatesTo = 'relates_to';
case Blocks = 'blocks';
case BlockedBy = 'blocked_by';
case Duplicates = 'duplicates';
public function inverse(): self
{
return match ($this) {
self::RelatesTo => self::RelatesTo,
self::Blocks => self::BlockedBy,
self::BlockedBy => self::Blocks,
self::Duplicates => self::Duplicates,
};
}
}Загрузка связей сущности идёт двумя условиями — где она источник и где она цель, — и во втором случае тип связи переворачивается.
Альтернатива с двумя записями проще в чтении и хуже во всём остальном: две записи надо синхронно создавать, синхронно удалять и как-то не давать им разъехаться. Одна запись плюс десять строк в энуме показались мне честнее.
Когда обобщённой связи не место
Первый сигнал отдельной сущности — собственные данные у связи.
Участие пользователя в проекте с ролью и датой приглашения — отдельная сущность с внешними ключами. Позиция задачи в спринте с порядковым номером — тоже. Одно техническое поле ещё ничего не решает; граница проходит там, где у связи появляются собственное поведение, жизненный цикл или данные, по которым делают выборку.
Второй признак — частота обращения. Связь «задача принадлежит проекту» читается в каждом запросе, по ней сортируют и фильтруют. Это обычный внешний ключ, и попытка обобщить его убила бы производительность списка задач.
Обобщённая таблица хороша ровно для того, для чего задумана: разреженные необязательные связи, которые показываются на карточке сущности и больше нигде не участвуют.
Через год
Девять таблиц не появились, появилась одна. Связей в ней около четырёх тысяч, самый частый тип — «относится к», самый полезный — «документирует», соединяющий задачу с её техническим заданием в базе знаний.
Единственное, чего мне не хватает, — представления в базе, которое разворачивало бы связи в читаемый вид для ручных запросов. Сейчас при отладке я каждый раз пишу выражение с условиями по типу. И обычное, и материализованное представление можно собрать через UNION ALL всех известных типов; я это откладываю, потому что при добавлении типа придётся менять ещё и представление.