Немного о работе с полиморфными связями в Eloquent

Всем доброго времени суток!

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

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

Все мы как правило работаем с обычными связями один-ко-многим, много-ко-многим и так далее. То есть как правило (если рассматривать отношение один-ко-многим) какая-то сущность имеет определенный набор принадлежащих ей других сущностей. Например, статья может иметь несколько комментариев. Но давайте представим, что на сайте теперь можо комментировать не только статьи, но и какие-то другие сущности, к примеру, фотографии. Вводить отдельно новую сущность «комментарий к фотографии» будет не оптимально и некрасиво. Логичнее было бы оставить прежнюю сущность «комментарий» в общем виде, просто дополнительно еще указывать, к какой сущности он принадлежит. Это и есть полиморфизм связи (эту часть статьи я посвящу отношению «один-ко-многим»/»много-к-одному»).

С точки зрения хранения в базе, реализовать это довольно просто: в таблицу comments  помимо id  сущности, на которую ссылается этот комментарий, добавляется еще одно поле — type , которое определяет, на какую именно сущность ссылается этот комментарий.

На практике чаще всего требуется получать потомков какой-то сущности, то есть в примере выше — имея статью нужно получить все ее комментарии. И в этом плане в Laravel я не заметил каких-либо упущений. Но я бы хотел больше остановиться на обратной операции — получения от потомка его родителя, и поэтому предлагаю рассмотреть другую задачу — небольшой упрощенный магазин электронных товаров.

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

Во-первых, у нас есть такая сущность, как «предмет» — Item . Item  представляет из себя описание предмета, когда-либо продававшегося на сайте, или который планируется начать продавать в будущем. Для простоты картины будем считать, что количество каждого предмета на складе у нас не ограничено.

Далее у нас есть сущность «Город» ( City ). Эта сущность представляет город, в котором осуществляется продажа предметов из нашего магазина. Аналогично Item ‘у — это список городов, в которых когда-либо осуществлялась продажа, либо планируется продажа в будущем.

Также у нас в магазине будет возможность продавать «наборы» предметов, то есть человек сможет увидеть в магазине «набор» как отдельный предмет и купить его, при этом эта покупка фактически означает покупку всех предметов, входящих в этот набор. Помимо этого, набор не просто включает себя какие-то предметы — каждый предмет входит в набор в определенном количестве. Итого имеем сущность «Набор» ( Set ). Аналогично предыдущим сущностям, Set  описывает набор предметов, который когда-либо продавался или который планируется продавать.

Предметы и наборы планируется продавать по разным ценам в разных городах, то есть цена предмета/набора привязывается не к предмету, а к городу, в котором он продается. То есть получаем некую обобщенную сущность «прайс-лист», которая является связующей между предметом(набором)-городом.

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

Итак, идем по порядку. Таблица items  будет содержать описания предметов. Она будет содержать следующие поля:

  • id — ID предмета
  • name — название предмета
  • description — описание предмета

Таблица sets  (наборы) будет такой же за исключением поля description  (в качестве описания набора будет текст о том, какие предметы и в каком количестве в него входят):

  • id — ID набора
  • name — название набора

Теперь нам нужна связующая таблица предметов и наборов item_set . Помимо ссылок на предмет и набор будет еще поле, указывающее, в каком количестве предмет входит в набор:

  • set_id — ID набора
  • items_id — ID предмета, входящего в набор
  • amount — количество

Далее — города. Тут все просто:

  • id — ID города
  • name — название города

Осталась основная таблица магазина — pricelist :

  • id — ID записи в таблице
  • city_id — город, к которому относится этот пункт прайс-листа
  • saleable_id — ID продаваемого предмета, либо ID продаваемого набора
  • saleable_type — определяет, на что ссылается поле saleable_id
  • cost — стоимость покупаемого предмета/набора

Как можем видеть, pricelist  как раз и является сущностью с полиморфной связью, так как она может принадлежать как сущности Item , так и сущности ItemSet . О конкретных значениях поля saleable_type  мы поговорим чуть позже — в нем и кроется один нюанс, о котором я хотел рассказать особо.

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

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

Начнем с моделей Item  и ItemSet . Оба эти класса представляют одну общую сущность (нечто продаваемое) и имеют одинаковое свойство name . Поэтому заведем для них один базовый класс Saleable  и отнаследуем от него два вышеупомянутых класса:

Для таблицы pricelist  создадим класс PriceListItem , представляющий один элемент из этой таблицы:

Остался еще класс City , он тоже совсем простой:

Имея сами классы для каждой сущности, можем приступать к определению связей. Начнем с привязки предметов к наборам. Предметы относятся к набору как много-ко-многим, то есть один и тот же предмет может входить в несколько разных наборов, а набор в свою очередь может содержать несколько разных предметов. Для такой связи используется метод belongsToMany :

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

У нас не простые связи предмет-набор, а параметризованные (предмет входит в набор в определенном количестве), поэтому в коде выше прошу обратить внимание на 8ю строчку: вызов метода withPivot . Он как раз подтягивает при выборке указанные дополнительные поля из связующей (pivot) таблицы. Позже мы сможем обращаться к ним.

Теперь приступим наконец к реализации полиморфных связей. В нашем примере не стоит задача получить у каждого предмета или набора всех его вхождений в прайс-лист, поэтому эти связи в моделях Item  и ItemSet  я тоже опущу и перейду сразу к модели PriceListItem .

В простейшем случае полиморфная связь должна вызвать и вернуть результат функции $this->morphTo() , но я распишу ее параметры подробнее:

Ключевыми параметрами являются второй и третий: если они указаны, первый параметр не используется. Если указан только первый параметр, значения оставшихся Laravel пытается угадать сам, а точнее просто дописать к нему _type  и _id . Если же вообще ни один параметр не указан, то имя Laravel подставит сам исходя из имени метода, вызвавшего morphTo , и уже на его основе подставит оставшиеся параметры. Так что в нашем случае мы могли просто вызвать $this->morphTo()  без параметров — Laravel бы автоматически подставил подходящие нам значения.

Теперь хочу вернуться немного назад и поговорить о том самом нюансе, который упомянул ранее, а именно — о значениях поля saleable_type . Делая выборки по полиморфным связям, Laravel считает, что поле type  содержит в чистом виде имя класса связанной сущности. То есть в нашем случае поле saleable_type  для Laravel’а должен быть VARCHAR  или ENUM , подразумевающий только два значения: Item  или ItemSet. Пока допустим, что это вполне вписывается в спроектированную вами базу данных, и рассмотрим, как работать с этой связью.

Например, чтобы выбрать полностью весь прайс-лист, подтянув сразу все связанные сущности, мы могли бы сделать так:

Результатом этой операции будет три SQL-запроса, если в прайс-листе встречаются как наборы, так и предметы:

Как это работает? Первым делом Eloquent делает выборку из самой таблицы pricelist  по указанным условиям (в нашем примере условия никакие не заданы, но мы могли бы ограничить выборку каким-либо городом).

Далее полученный список обходится и формируется словарь по значениям поля saleable_type : в этом примере в выборку попало 3 набора ( saleable_type == 'ItemSet' ) со значениями id  == 1, 3, 4. Аналогично для предметов: попали пункты прайс-листа, ссылающиеся на предметы 6, 7, 3, 5, 4, 16. Сформировав словарь различных типов, Eloquent делает ленивую загрузку (eager loading) всех связанных полей в каждой таблице соответствующей сущности, после чего заполняет соответствующие объекты в каждом конкретном экземпляре PriceListItem .

Вроде бы все идет хорошо, но тут есть одна маленькая недоработка в Laravel, с которой я столкнулся. Дело в том, что часто база проектируется не только до того, как начнет писаться код, но и даже до того, как вы определитесь с тем, на каком фреймворке вы будете писать этот код. Соответственно, значения связующего поля type могут быть абсолютно произвольными. К примеру в нашем случае, поле saleable_type  могло бы быть ENUM ‘ом с допустимыми значениями ITEM  и SET . А проблема заключается в том, что определяя полиморфную связь в Eloquent на данный момент, что называется «из коробки», никак нельзя задать произвольную «карту» соответствий значений поля type  и моделей, на которые они ссылаются. Иными словами, Laravel просто не поймет, что при виде ITEM  нужно использовать модель Item , а при виде SET  — ItemSet . Решению этой проблемы я и посвящу оставшуюся часть статьи.

Для начала давайте определимся, как нам было бы удобнее использоать Eloquent, чтобы мы могли научить его делать соответствия между значениями поля type и моделями. Наиболее удачным мне кажется вариант, при котором мы бы передавали карту соответствий в определения полиморфной связи, то есть, например, так:

То есть мы просто добавили еще один аргумент в определение связи, в котором передали карту соответствий (так называемый маппинг).

Следующий шаг — переопределить сам метод morphTo  модели, чтобы он принимал этот параметр.  Тут возможны несколько вариантов … Самый простой и банальный — переопределить этот метод чисто в этой модели, в которой используется такая связь. Но этот метод подойдет только в том случае, если вы на 100% уверены, что больше нигде в проекте не будет других моделей с подобной связью, в противном случае копи-паст неизбежен. Следующий вариант, логично вытекающий из предыдущего — сделать свою новую базовую модель, наследующую Eloquent, в котором будет переопределен этот метод, а все модели с полиморфными связями наследовать не от Eloquent, а от этой новой модели. Этот метод уже лучше, так позволяет избежать копи-пасты. Но все же он не идеален: что, если у вас в проекте уже имеется своя базовая модель и менять ее не хочется? Или другой случай — вы используете базовую модель из какого-то стороннего пакета, а менять ее просто нет возможности? Такие случае, конечно, редки, но все же могут иметь место. Поэтому самым оптимальным решением на мой взгляд будет создание трейта, реализующего этот переопределенный метод.

Идем далее. Теперь нам нужно немного разобраться в том, что твориться внутри метода morphTo  по умолчанию в Eloquent. В версии 4.2 он выглядит следующим образом (оригинальные комментарии я удалил для сокращения текста, но прокомментирую ниже своими словами):

Итак, что мы видим? Здесь есть три блока. Первый (строки с 3й по 9ю) — это заполнение параметров связи, если они не были указаны явно (это я рассмотрел ранее). Далее (строки 11-16) — это обработка случая, когда мы получаем связанные сущности посредством «жадной» загрузки (eager loading). То есть если текущее значение в поле type пусто (а оно будет пустым, если мы формируем запрос вида PriceListItem::with('saleable')->get();  — будет создана «болванка» класса PriceListItem , не привязанная к конкретной строке в таблице pricelist ) — значит можно смело предполагать, что происходит формирование eager-loading-запроса. В противном случае (третий блок, строки 18-24) мы создаем пустой экземпляр класса, на который ссылается это поле type  и возвращаем объект, описывающий связь, используя в качестве некоторых из аргументов новый запрос на основе этой модели и имя ключа этой связанной модели.

Я полагаю, что полное понимание этого кода потребует от вас некоторых нетривиальных знаний в Eloquent, но все не так сложно, как может показаться на первый взгляд 🙂

Итак, как видим, метод morphTo , во-первых, в любом случае вернет объект MorphTo , описывающий полиморфную связь «много-к-одному», и во-вторых, в одном из случаев уже в нем происходит инстанциирование класса, указанного в поле type  (строка 19), а наша задача, напомню, научить Eloquent делать маппинг значений поля type  к конкретным моделям. Но это еще не все — копаем дальше, так как пока непонятно, как происходит обработка «жадной» загрузки.

Переходим в класс MorphTo . Желающие могут проследить в нем логику, описанную мной ранее, но я сейчас не буду разбирать подробно весь его код и сразу перейду к интересующим нас методам. Нас интересует метод getResultByType($type) : в нем как раз и происходит выборка всех родительских объектов в зависимости от значения, указанного в поле type . Аналогично, тут происходит инстанциирование класса, указанного в этом поле посредством вызова метода createModelByType($type)  — именно его нам и нужно научить делать маппинг.

Чтобы решить эту задачу, нам нужен собственный класс MorphTo , который переопределит эту логику. И первое, что, очевидно, нужно сделать — это изменить конструктор, чтобы он принимал карту соответствий. Давайте назовем этот класс MappedMorphTo :

Как видим, у нас добавился новый параметр в конструкторе, в который мы передаем карту. А логика измененного метода createModelByType  довольно проста: мы проверяем, задана ли карта соответствий. Если да, то подменяем значение переменной $type  из этой карты, после чего происходит вызов родительского метода, который просто сделает return new $type; . Половина дела сделана.

Теперь вернемся снова к нашему трейту и проделаем схожие действия. Он будет выглядеть примерно так:

Здесь аналогично мы принимаем дополнительный параметр в конструкторе ( $mappings ) и запоминаем его. А в строках 27-35 мы реализуем похожую логику, что и в самом классе MappedMorphTo .

На этом, в принципе, все 🙂 Остается прописать в нашей модели  PriceListItem  инструкцию  use MappedPolymorphModel; .

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

Я же, по мере появления у меня свободного времени, оформлю все вышеизложенное в красивый pull-request Тэйлору в надежде, что он его одобрит 🙂 А если нет — напишу отдельный пакет.

Вот теперь действительно все 🙂 Всем спасибо за внимание и удачи!

Поделиться в соц. сетях

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Лимит времени истёк. Пожалуйста, перезагрузите CAPTCHA.