Тема полиморфных связей уже поднималась не раз на Хабре («Rails и полиморфные связи», «Полиморфные сквозные ассоциации в Ruby on Rails», «Полиморфные связи»), но поднималась она в контексте Ruby, и для тех, кто уже имеет какой-то опыт в проектировании БД. Новичкам же (мне было), мало что понятно из тех статей, поэтому в данной статье я попытаюсь рассказать всё на пальцах, абстрагируясь от языка, разве что немного задену ORM популярных фреймворков в вебе.
Всем понятны обычные «взаимоотношения» табличек в реляционных БД: один-к-одному, один-ко-многим, многие-ко-многим. А если не понятны, то вот вам простые примеры их реализации.
Один-к-одному. Одной записи из первой таблицы, соответствует только одна запись из второй таблицы. Тут всё просто. Самый распространенный пример — таблица user и user_profile (Каждому пользователю, соответствует один профиль).
Один-ко-многим. Связь строится таким образом, что каждой записи в одной таблице может соответствовать несколько записей из другой таблицы. Пример — таблица articles (статьи), таблица comments (комментарии). К одной статье может быть оставлено множество комментариев.
Многие-ко-многим. Связь реализуется, когда одной строке из одной таблицы может соответствовать несколько записей из другой и наоборот. Хороший пример — имеется таблица статей (articles), имеется таблица тегов (tags), связываются они через промежуточную таблицу (pivot table или junction table) tags_articles, в которой есть article_id, tag_id.
Вроде, всё тут просто и понятно.
Откуда же взялись какие-то полиморфные связи, если и так предыдущие связи вполне логичны и как будто, не требуют дополнений?
Предыдущие связи (один-к-одному, один-ко-многим, многие-ко-многим), создаются для статичных сущностей из таблиц, на которые можно навесить ограничения (constraints) предоставляемые СУБД.
Вернемся к примеру связи один-ко-многим.
+--------------+ | articles | | comments | +--------------+
articles:
+----+--------------------------------------------------------+------------+ | id | text | date | +----+--------------------------------------------------------+------------+ | 1 | Текст крутой статьи | 2015-07-05 | | 2 | Текст еще одной крутой статьи | 2015-07-05 | +----+--------------------------------------------------------+------------+
comments:
+----+----------------------------------------------------------------+------------+------------+ | id | text | article_id | created_at | +----+-----------------------------------------------------------------------------+------------+ | 1 | Неплохой коммент | 1 | 2015-07-05 | | 2 | Неплохой коммент | 1 | 2015-07-05 | | 3 | Неплохой коммент | 2 | 2015-07-05 | +----+----------------------------------------------------------------+------------+------------+
В таблице comments article_id — это id статьи из таблицы articles. Всё очевидно. Но! Что если, завтра у нас появляется необходимость создать таблицу news (новостей) и для нее тоже нужно добавить функционал комментариев?!
При известных нам типах связей между таблицами, варианта появляется два:
1) Создать новую таблицу comments (напр. comments_news) с идентичной структурой, как у таблицы comments, но вместо article_id, поставить news_id.
2) В существующую таблицу comments добавить еще один столбец news_id рядом с article_id.
В обоих случаях получается как-то коряво. Если завтра нужно будет добавить функционал комментариев к еще одной — третьей таблице (напр. к постам пользователей или к картинкам), нам придётся создать еще одну таблицу или третье поле в существующей таблице? Пятое-десятое? Не то… Тут на помощь и приходят полиморфные связи.
Суть полиморфных связей
Полиморфные связи — это динамические связи между таблицами с использованием типа сущности.
Чтобы было понятно, поменяем немного наши таблицы и сделаем между ними полиморфные связи.
Наша еще одна таблица — news:
+----+--------------------------------+------------+ | id | text | date | +----+--------------------------------+------------+ | 1 | Какая-то новость | 2015-07-05 | +----+--------------------------------+------------+
И меняем таблицу comments, чтобы стало, ровно!
comments:
+----+----------------------------------------------------+-----------+-------------+------------+ | id | text | entity_id | entity_type | created_at | +----+----------------------------------------------------+-----------+-------------+------------+ | 1 | Неплохой коммент | 1 | article | 2015-07-05 | | 2 | Неплохой коммент | 1 | article | 2015-07-05 | | 3 | Неплохой коммент | 2 | article | 2015-07-05 | | 4 | Коммент | 1 | news | 2015-07-05 | +----+----------------------------------------------------+-----------+-------------+------------+
Суть полиморфных связей становится ясна, при просмотре таблицы comments — entity_id — id какой-то сущности, к которой мы оставляем комментарий, entity_type — тип этой самой сущности. Ни entity_id, ни entity_type — заранее неизвестны, поэтому эти связи можно назвать динамическими.
Использовать полиморфные связи стоит тогда, когда у нас появляется две и более таблицы, у которых будет связь один-ко-многим с какой-то другой одной и той же таблицей (articles-comments, news-comments, posts-comments и т.д.). Если же, у вас есть связи только между 2 таблицами и больше не предусматривается, полиморфные лучше заменить на обычные один-ко-многим.
Полиморфные связи могут быть реализованы, и как многие-ко-многим.
Показывать таблицы с данными не имеет смысла, покажу лишь примерную структуру.
articles:
id — integer
text — text
posts:
id — integer
text — text
tags:
id — integer
name — string
tags_entities
tag_id — integer
tag_entity_id — integer
tag_entity_type — string (post|article)
Минусы полиморфных связей
Не всё так идеально, как могло бы показаться на первый взгляд. В силу своей динамической природы полиморфных связей, между полями связуемых таблиц, нельзя проставить связи внешних ключей (foreign key) используя СУБД, а тем более и ограничения (constraints) на изменение или удаление записей. Это, собственно самый большой минус полиморфных связей. Придется, либо писать свои триггеры (процедуры или еще что) для самой СУБД, либо, что чаще делают, переложить работу по синхронизации строк и накладыванию ограничений между таблицами на ORM и язык программирования.
Второй, уже менее значительный минус полиморфных связей состоит в типе сущности. Необходимо как-то описать какой тип, какой таблице принадлежит. Это может быть не очевидно, если например название какой-то таблицы изменилось или если вы задали тип сущности цифрами. Решить эту проблему можно, например создав отдельную таблицу, или прописав в коде проекта, ассоциативный массив с сопоставлением типа и сущности.
Работа ORM с полиморфными связями
Следует сказать, что современные фреймворки и их ORM без особых сложностей, способны работать с данными связями.
Например, как уже говорилось выше, Ruby on Rails поддерживает их из коробки. Php-фреймворк Laravel, также имеет в своей реализации ORM для таких типов связей удобные методы (morphTo, morphMany и т.д.), а как тип сущности использует полное название класса модели. Во фреймворке Yii2, нет из коробки каких-то специфичных методов для такого рода связей, но они могут быть реализованы через обычные методы hasOne, hasMany с дополнительными условиями при прописывании связей.
Из всего вышесказанного, новичкам стоит обратить внимание на то, когда использовать полиморфные связи. Не стоит их пихать направо и налево, из проекта в проект, только потому что это круто. Нужно немного прикинуть наперед, а появятся ли завтра новые таблицы, новые сущности с одинаковым функционалом и требованиями, которые можно было бы вынести и сделать динамическими, и исходя от ответа проектировать свои БД.
Комментарии (39)
WildZero
05.07.2015 16:56И начнутся проблемы, когда к комментариям к новости нудно будет добавить парочку обязательных полей, которых не должно быть в комментах к статье.
nekt
05.07.2015 20:32Для поддержки foreign_key вполне можно еще немного изменить структуру БД: к таблицам news и articles добавляется поле entity_id, которое автоматически ( триггером или в коде ) заполняется при создании записи и является внешним ключем с полем id в таблице entities, к которому уже и коннектятся все эти таблицы с комментариями, лайками, отметками избранного, тэгами, разделами и прочее.
nekt
05.07.2015 20:36+1А можно пойти еще дальше и сделать единую нумерацию айдишников для всех сущностей в базе.
При создании чего угодно сначала создается entity с полями id и type, после чего id переносится в поле id нужной сущности. И все связи идут по общему айдишнику — становится не важен тип записи, которая линкуется, поскольку id однозначно определяет тип.SamDark
06.07.2015 13:33В разных таблицах? А constraint?
BlessMaster
06.07.2015 15:01Constraint — на единую таблицу entities.
Без дополнительного запроса к этой таблице тип автоматически известен не станет, здесь никакой магии.
Магия появляется с адресацией — нет необходимости указывать тип ресурса в дополнение к id.
BlessMaster
06.07.2015 14:54И у данной схемы также есть свои минусы: таблица entities станет узким местом в системе, с одной стороны требуя для своего поддержания дополнительные индексы по большому количеству записей, с другой — обслуживание этой таблицы (в системах требующих такого обслуживания) межет стать проблемой. Так же как мы захотели сделать единую систему комментариев для статей, следом за статьями нам рано или поздно захочется сделать сущностями и сами комментарии, для какой-нибудь системы оценок и рассчёта рейтинга. Хотя этот вариант мне кажется лучшим для небольших систем, но к нему стоит также подходить осторожно.
nekt
06.07.2015 21:41Именно так. В максимальном своем развитии эта идея ведет к БД на графах со всеми их плюсами и минусами. Из плюсов — легкость реализации сложной логики взаимодействия. Из минусов — отсутствие возможности масштабирования. С другой стороны не так много систем существует, которым реально надо больше одного сервера.
lexxpavlov
05.07.2015 22:33В Doctrine Extesions есть поведение для переводов сущностей (Translatable), использующее подобный подход. Но там можно переключить конкретные сущности на персональные таблицы для переводов.
А для тегов в symfony я использовал бандл FPNTagBundle, использующий подход, описанный в этой статье, как раз Many-to-Many. Правда, в сонате они не поддерживаются, и там в классе админки нужно их вручную считывать из базы. Я писал об использовании тегов в Symfony и Sonata в своей статье.
alekciy
06.07.2015 14:38+2Поскольку конкретная РСУБД тут не фигурирует, то рекомендую для приведенной задачи просто используйте Postgresql с его наследованием таблиц.
BlessMaster
06.07.2015 15:08Более того, для ряда задач отпадает необходимость вообще в задействовании связей — Postgres предлагает ряд приятных «дополнительных» типов вроде Array, HStore, JSON(B).
coh
06.07.2015 17:32+1У «Полиморфных связей» есть еще одна очевидная проблема — разрастание таблицы и сложность горизонтального масштабирования.
overmes
06.07.2015 18:29В django тоже есть, называется Content Types
JC_Piligrim
09.07.2015 01:04За пределами Django в Python очень хорошо с ними ещё умеет работать SqlAlchemy, рекомендую! Используем давно, постоянно и радуемся. :)
jacob1237
07.07.2015 11:53Тогда уж к ряду следовало бы упомянуть про «SQL Antipatterns: Avoiding the Pitfalls of Database Programming» от Bill Karwin.
Там есть раздел, посвященный полиморфным ассоциациям.
Кстати на stackoverflow он часто отвечал на вопросы по этому поводу, разбирая всё на примерах.
Вот даже презентация есть: www.slideshare.net/billkarwin/sql-antipatterns-strike-back
Самый главный минус полиморфных связей, как уже сказали — отсутствие контроля целостности данных. Его придется реализовывать программно.
Всё-таки этот тип связей не совсем вписывается в концепцию реляционных баз данных.
Ну и когда будете проектировать БД, нужно будет подумать над тем, захотите ли Вы в дальнейшем делать общую выборку по таблице с полиморфными связями.
И если вдруг захотите, а связанных таблиц у Вас будет больше, например, 5-ти, подумайте, как будет выглядеть сам запрос и сколько JOIN'ов нужно будет сделать, а самое главное — как это скажется на производительности.
XanderBass
07.07.2015 12:12В разрабатываемом мною движке есть одна любопытная таблица. Назовём её условно fields. Ниже SQL-код её создания:
CREATE TABLE IF NOT EXISTS `[+prefix+]fields` (
`id` INT NOT NULL AUTO_INCREMENT,
`module` TINYINT NOT NULL DEFAULT '0' COMMENT 'See below',
`type` INT NOT NULL DEFAULT '0' COMMENT 'See below',
`handlers` INT NOT NULL DEFAULT '0' COMMENT 'See below',
`input` INT NOT NULL DEFAULT '0',
`alias` VARCHAR(64),
`caption` TINYTEXT NULL,
`description` TINYTEXT NULL,
`category` INT NULL,
`elements` TEXT NULL,
`default` TEXT NULL,
`flags` INT NOT NULL DEFAULT '0',
`rank` INT NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `alias`(`alias`),
FOREIGN KEY (`category`) REFERENCES `[+prefix+]categories`(`id`) ON UPDATE SET NULL ON DELETE SET NULL
) ENGINE=InnoDB COMMENT='Data fields' INSERT_METHOD=FIRST;
Это таблица полей дополнительных данных. Поля могут быть полями т.н. филдсетов для ресурсов, настройками самого движка, отдельных модулей, дополнительными полями пользователей или даже доп.полями данных модулей. В любом случае таблицы со значениями всегда ссылаются на поле id этой таблицы. Поле module означает тип дополнительного поля (поле филдсета, настройка и т.п.). Собственно, поле module по большому счёту обрабатывается уже самим PHP. Это, так сказать, в качестве хорошего примера.
Теперь к сабжу. Почему бы не реализовать функционал проще? Например, таблицы articles и news — это на самом деле одна таблица, а комментарии в любом случае будут ссылаться на эту таблицу. Как говорится, зачем плодить лишние сущности? В принципе у описанного мною подхода есть, конечно, существенный недостаток. Скорость чтения записей из одной таблицы будет несколько ниже, особенно на большом количестве данных. Однако, во-первых, будем реалистами. Каковы могут быть объёмы данных в среднестатистических проектах. Во-вторых, архитектура источника данных на множестве таблиц усложняет расширение проекта в дальнейшем. И опять-таки же, если уж так хочется разных таблиц, почему бы не сделать таблицу комментариев к каждому типу отдельно?
JSmitty
09.07.2015 00:51Уже высказались по целостности БД. Свои 5 копеек — в 2002 изобрел такой же велосипед в постгресе для интернет-магазина (хранение картинок для категорий, товаров и общесистемных), написал триггеры, которые проверяли и выполняли ограничения (всякие там каскадные удаления etc). За долгие годы эксплуатации движка пожалел об этом неоднократно. Минусы сильно перевешивают плюсы. Даже сквозная нумерация записей в разных таблицах не спасает (опять же, в постгресе это делается на раз). Наследование тогда использовать побоялся (были мысли о кроссплатформенности по БД) — как чисто постгресную фишку.
Сейчас — однозначно только PostgreSQL и только наследование таблиц для полиморфизма сущностей.
Khaperets
15.07.2015 08:49К примеру: если нужно сделать связь комментариев (comments) с товарами (products), новостями (news), статьями (articles), то лучше бы просто делать связи в отдельных промежуточных таблицах — products_comments (product_id, comment_id), news_comments (news_id, comment_id) и articles_comments (article_id, comment_id).
Тогда можно навесить ограничения (constraints) и намного проще для понимания.savio
15.07.2015 09:14Я так же за промежуточные таблицы, но по другим соображениям. А что если мне нужно переименовать тип(например с news на articles)?
На мой взгляд в промежуточных таблицах нужно хранить id типа. Да, мы получим дополнительную таблицу types с id и name, но все же
3axap4eHko
Добавляем к таблице
поле и задача решается в рамка БД, а не на уровне ORM. И вообще говоря, на php есть только одна ORM это Doctrine, все остальное так или иначе Mapper'ыbooomerang Автор
Первая строчка из документации Laravel:
И на вики есть такой линк.
Точно не понятно, как вы отделяете Doctrine от остальных реализаций? И кстати, а в доктрине есть что-то по полиморфным связям?
3axap4eHko
Исходя из определения ORM (object-relational mapping) можно провести следующий тест, является ли библиотека ORM:
В итоге, следующее выражение для ORM должно выполнено успешно:
Это тест на консистентность любой ORM и то как она разрешает связи. Дальнейшее обсуждение считаю бессмысленным.
Blumfontein
Ну Propel2 под это определение подходит, вроде как.
Fesor
Как бы да, но Propel2 еще в альфе. И я правильно понимаю что там будет уже не Active Record а data mapper?
Blumfontein
Релиза не дождешься. Мы его на реальных проектах со второй альфы используем, особых багов и проблем с обновлением до 3 и 4 альфы не было.
>> И я правильно понимаю что там будет уже не Active Record а data mapper?
Последняя версия сейчас alpha-4 и это чистый Active Record. Помнится, я читал ветку на Гитхабе с грандиозным срачем на тему data mapper-а, и там предлагался некий гибридный вариант ORM. Честно говоря, не знаю, какой статус у того предложения сейчас, и ветку эту не могу найти сейчас.
Big_Shark
github.com/propelorm/Propel2/pull/795 вот эта ветка, и она по чуть-чуть развивается.
Fesor
Гибридный вариант мне совсем не нравится, но меня интригуют заявления о том что тамошняя реализация UoW работает в 4 раза быстрее чем вариант доктрины… правда я думаю что когда функционал закончат разницы почти не будет. А если так то лучше остаться на доктрине, в которой намного больше возможностей.
Blumfontein
propelorm.org/blog/2015/06/27/propel2-current-state-and-future.html
Накопал больше информации. Исходя из этого поста, курс на data-mapper вроде как свершившийся факт. Там же написано, что Active Record остается опциональным вариантом, т.е. в схеме для какого-то entity можно включить ActiveRecord, и генератор сгенерирует persistance-методы внутри модели.
Tab10id
Стоит уточнить, что у полиморфных сущностей так же имеются свои минусы, так же как и у полиморфных связей. Все сильно зависит от ситуации.
Для примера мы можем захотеть комментировать нечто совершенно отличающееся от новостей и статей. Сущность «Товар» уж наверняка не стоит пихать в таблицу articles. Хотя технически ничто этого сделать не помешает.