Недавно, делая очередной функционал на одном из проектов, я столкнулся с немного необычными связями в реляционных СУБД, у которых, как оказалась позже, есть замысловатое название — Полиморфные связи. Что это такое, как и где их применять, я попытаюсь объяснить в данной статье.

Тема полиморфных связей уже поднималась не раз на Хабре («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)


  1. 3axap4eHko
    05.07.2015 16:49
    +3

    Добавляем к таблице

    articles
    поле
    type
    и задача решается в рамка БД, а не на уровне ORM. И вообще говоря, на php есть только одна ORM это Doctrine, все остальное так или иначе Mapper'ы


    1. booomerang Автор
      05.07.2015 17:44
      -4

      Первая строчка из документации Laravel:

      The Eloquent ORM included with Laravel provides a beautiful, simple ActiveRecord implementation for working with your database.

      И на вики есть такой линк.
      Точно не понятно, как вы отделяете Doctrine от остальных реализаций? И кстати, а в доктрине есть что-то по полиморфным связям?


      1. 3axap4eHko
        05.07.2015 18:28

        Исходя из определения ORM (object-relational mapping) можно провести следующий тест, является ли библиотека ORM:

        1. Создаем связную сущность:
          $a = new A();
          $a->b=new B();
          

        2. Сохраняем только $a
          orm.save($a);
          

        3. Затем извлекаем дважды одну и ту же сущность:
          $a1=orm.findById(1);
          $a2=orm.findById(1);
          


        В итоге, следующее выражение для ORM должно выполнено успешно:
        AssertTrue($a1 === $a2 && $a1->b===$a2->b)
        

        Это тест на консистентность любой ORM и то как она разрешает связи. Дальнейшее обсуждение считаю бессмысленным.


        1. Blumfontein
          06.07.2015 08:48

          Ну Propel2 под это определение подходит, вроде как.


          1. Fesor
            06.07.2015 09:20

            Как бы да, но Propel2 еще в альфе. И я правильно понимаю что там будет уже не Active Record а data mapper?


            1. Blumfontein
              06.07.2015 14:45

              Релиза не дождешься. Мы его на реальных проектах со второй альфы используем, особых багов и проблем с обновлением до 3 и 4 альфы не было.

              >> И я правильно понимаю что там будет уже не Active Record а data mapper?

              Последняя версия сейчас alpha-4 и это чистый Active Record. Помнится, я читал ветку на Гитхабе с грандиозным срачем на тему data mapper-а, и там предлагался некий гибридный вариант ORM. Честно говоря, не знаю, какой статус у того предложения сейчас, и ветку эту не могу найти сейчас.


              1. Big_Shark
                06.07.2015 16:38

                github.com/propelorm/Propel2/pull/795 вот эта ветка, и она по чуть-чуть развивается.


              1. Fesor
                06.07.2015 16:43

                Гибридный вариант мне совсем не нравится, но меня интригуют заявления о том что тамошняя реализация UoW работает в 4 раза быстрее чем вариант доктрины… правда я думаю что когда функционал закончат разницы почти не будет. А если так то лучше остаться на доктрине, в которой намного больше возможностей.


                1. Blumfontein
                  07.07.2015 07:41

                  propelorm.org/blog/2015/06/27/propel2-current-state-and-future.html

                  Накопал больше информации. Исходя из этого поста, курс на data-mapper вроде как свершившийся факт. Там же написано, что Active Record остается опциональным вариантом, т.е. в схеме для какого-то entity можно включить ActiveRecord, и генератор сгенерирует persistance-методы внутри модели.


    1. Tab10id
      05.07.2015 23:05

      Стоит уточнить, что у полиморфных сущностей так же имеются свои минусы, так же как и у полиморфных связей. Все сильно зависит от ситуации.
      Для примера мы можем захотеть комментировать нечто совершенно отличающееся от новостей и статей. Сущность «Товар» уж наверняка не стоит пихать в таблицу articles. Хотя технически ничто этого сделать не помешает.


  1. WildZero
    05.07.2015 16:56

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


    1. Dzorogh
      05.07.2015 17:47

      А как бы вы реализовали схему, имея только условия из статьи и не зная изначально о доп. полях?


      1. WildZero
        05.07.2015 18:11

        Скорее всего, так же бы и реализовал. Комментарий был не из разряда «полиморфные связи — отстой», а как обычное наблюдение из жизни.


      1. Temirkhan
        05.07.2015 20:31

        А почему нельзя в таблице comments поставить поля target и target_id?


  1. nekt
    05.07.2015 20:32

    Для поддержки foreign_key вполне можно еще немного изменить структуру БД: к таблицам news и articles добавляется поле entity_id, которое автоматически ( триггером или в коде ) заполняется при создании записи и является внешним ключем с полем id в таблице entities, к которому уже и коннектятся все эти таблицы с комментариями, лайками, отметками избранного, тэгами, разделами и прочее.


    1. nekt
      05.07.2015 20:36
      +1

      А можно пойти еще дальше и сделать единую нумерацию айдишников для всех сущностей в базе.

      При создании чего угодно сначала создается entity с полями id и type, после чего id переносится в поле id нужной сущности. И все связи идут по общему айдишнику — становится не важен тип записи, которая линкуется, поскольку id однозначно определяет тип.


      1. SamDark
        06.07.2015 13:33

        В разных таблицах? А constraint?


        1. BlessMaster
          06.07.2015 15:01

          Constraint — на единую таблицу entities.
          Без дополнительного запроса к этой таблице тип автоматически известен не станет, здесь никакой магии.
          Магия появляется с адресацией — нет необходимости указывать тип ресурса в дополнение к id.


      1. BlessMaster
        06.07.2015 14:54

        И у данной схемы также есть свои минусы: таблица entities станет узким местом в системе, с одной стороны требуя для своего поддержания дополнительные индексы по большому количеству записей, с другой — обслуживание этой таблицы (в системах требующих такого обслуживания) межет стать проблемой. Так же как мы захотели сделать единую систему комментариев для статей, следом за статьями нам рано или поздно захочется сделать сущностями и сами комментарии, для какой-нибудь системы оценок и рассчёта рейтинга. Хотя этот вариант мне кажется лучшим для небольших систем, но к нему стоит также подходить осторожно.


        1. nekt
          06.07.2015 21:41

          Именно так. В максимальном своем развитии эта идея ведет к БД на графах со всеми их плюсами и минусами. Из плюсов — легкость реализации сложной логики взаимодействия. Из минусов — отсутствие возможности масштабирования. С другой стороны не так много систем существует, которым реально надо больше одного сервера.


  1. hlogeon
    05.07.2015 21:06
    +1

    Неужели связь через pivot table будет работать дольше полиморфной связи?


  1. lexxpavlov
    05.07.2015 22:33

    В Doctrine Extesions есть поведение для переводов сущностей (Translatable), использующее подобный подход. Но там можно переключить конкретные сущности на персональные таблицы для переводов.
    А для тегов в symfony я использовал бандл FPNTagBundle, использующий подход, описанный в этой статье, как раз Many-to-Many. Правда, в сонате они не поддерживаются, и там в классе админки нужно их вручную считывать из базы. Я писал об использовании тегов в Symfony и Sonata в своей статье.


  1. alekciy
    06.07.2015 14:38
    +2

    Поскольку конкретная РСУБД тут не фигурирует, то рекомендую для приведенной задачи просто используйте Postgresql с его наследованием таблиц.


    1. BlessMaster
      06.07.2015 15:08

      Более того, для ряда задач отпадает необходимость вообще в задействовании связей — Postgres предлагает ряд приятных «дополнительных» типов вроде Array, HStore, JSON(B).


      1. hlogeon
        06.07.2015 16:28

        Ну так уж можно залезть в то, что на Postgres можно намутить NoSQL


        1. WildZero
          06.07.2015 17:06

          Ну jsonb в 9.5 прекрасно этому способствует


          1. Fesor
            06.07.2015 17:15

            в 9.4


          1. hlogeon
            06.07.2015 17:42

            Это я только к тому, что тема данной статьи все же немного другая.


          1. nekt
            06.07.2015 21:42

            hstore тоже для этого годится — я еще лет 10 назад NoSQL на постгресе с ним мутил. Очень приятные впечатления.


  1. coh
    06.07.2015 17:32
    +1

    У «Полиморфных связей» есть еще одна очевидная проблема — разрастание таблицы и сложность горизонтального масштабирования.


  1. overmes
    06.07.2015 18:29

    В django тоже есть, называется Content Types


    1. JC_Piligrim
      09.07.2015 01:04

      За пределами Django в Python очень хорошо с ними ещё умеет работать SqlAlchemy, рекомендую! Используем давно, постоянно и радуемся. :)


  1. 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'ов нужно будет сделать, а самое главное — как это скажется на производительности.


  1. 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 — это на самом деле одна таблица, а комментарии в любом случае будут ссылаться на эту таблицу. Как говорится, зачем плодить лишние сущности? В принципе у описанного мною подхода есть, конечно, существенный недостаток. Скорость чтения записей из одной таблицы будет несколько ниже, особенно на большом количестве данных. Однако, во-первых, будем реалистами. Каковы могут быть объёмы данных в среднестатистических проектах. Во-вторых, архитектура источника данных на множестве таблиц усложняет расширение проекта в дальнейшем. И опять-таки же, если уж так хочется разных таблиц, почему бы не сделать таблицу комментариев к каждому типу отдельно?


  1. JSmitty
    09.07.2015 00:51

    Уже высказались по целостности БД. Свои 5 копеек — в 2002 изобрел такой же велосипед в постгресе для интернет-магазина (хранение картинок для категорий, товаров и общесистемных), написал триггеры, которые проверяли и выполняли ограничения (всякие там каскадные удаления etc). За долгие годы эксплуатации движка пожалел об этом неоднократно. Минусы сильно перевешивают плюсы. Даже сквозная нумерация записей в разных таблицах не спасает (опять же, в постгресе это делается на раз). Наследование тогда использовать побоялся (были мысли о кроссплатформенности по БД) — как чисто постгресную фишку.
    Сейчас — однозначно только PostgreSQL и только наследование таблиц для полиморфизма сущностей.


  1. savio
    11.07.2015 23:35
    +1

    А я когда-то изобрел это, только не знал, что название сие полимерная связь :)


    1. Khaperets
      15.07.2015 08:50

      Полиморфная)


  1. 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) и намного проще для понимания.


    1. savio
      15.07.2015 09:14

      Я так же за промежуточные таблицы, но по другим соображениям. А что если мне нужно переименовать тип(например с news на articles)?
      На мой взгляд в промежуточных таблицах нужно хранить id типа. Да, мы получим дополнительную таблицу types с id и name, но все же