Pull to refresh

Полиморфные сквозные ассоциации в Ruby on Rails

Reading time 4 min
Views 15K
В статье идет речь о методе создания полиморфизма для связей many-to-many в Ruby on Rails.

Задача


Допустим, что необходимо разработать систему управления грузовым транспортом. В нашем распоряжении имеются несколько видов этого транспорта: поезда, вертолеты, грузовики и баржи. И известно, что каждое средство осуществляет перевозку только в строго определенные населенные пункты. Например, часть грузовиков катается по центральной части России, часть по южной, вертолеты работают в Сибири и на Камчатке, поезда вообще ограничены железнодорожным полотном и так далее.
Каждый вид транспорта в разрабатываемой системе будет представлен своим классом: Train, Copter, Truck, Ship соответственно.
Населенные пункты (города, поселки, научные станции, тут нас интересует не размер, а географические координаты), куда осуществляется перевозка, представлены классом Location.
Стоит условие: к каждой единице транспорта может быть привязано сколько угодно Location. В свою очередь к каждому населенному пункту может быть привязано сколько угодно единиц транспорта разных видов.



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

Неоптимальное решение


Первое, что приходит в голову, создать четыре служебные транзитивные таблицы, которые будут объединять каждый вид транспорта с населенными пунктами.
class Train < ActiveRecord::Base
  has_many :train_locations, dependent: :destroy
  has_many :locations, through: :train_locations
end

class TrainLocation < ActiveRecord::Base
  belongs_to :train
  belongs_to :location
end

Посмотреть код полностью

И класс Location, который ссылается на все 4 вида транспорта
class Location < ActiveRecord::Base
  has_many :train_locations, dependent: :destroy
  has_many :ship_locations, dependent: :destroy  
  has_many :copter_locations, dependent: :destroy  
  has_many :truck_locations, dependent: :destroy

  has_many :trains, :through => :train_locations
  has_many :ships, :through => :ship_locations
  has_many :copters, :through => :copter_locations
  has_many :trucks, :through => :truck_locations
end

Уффф… Кажется тут получилось 9 таблиц, 9 моделей и куча однородного кода. Не кажется ли, что слишком много для реализации одной связи? А если будет 10 видов транспорта, то потребуется 21 таблица и 21 модель для реализации?
Почему бы не попробовать использовать полиморфизм в одной транзитивной таблице?
Сказано — сделано!

Предварительное решение


Создаем миграцию:
class CreateMoveableLocations < ActiveRecord::Migration
  def change
    create_table :moveable_locations do |t|
      t.references :location
      t.references :moveable, polymorphic: true
      t.timestamps
    end
  end
end

Да, я понимаю, что moveable — не самое удачное название, но оно лучше, чем transportable.

Далее, создаем класс для хранения ассоциаций:
class MoveableLocation < ActiveRecord::Base
  belongs_to :location
  belongs_to :moveable, polymorphic: true
end

Создаем классы для видов транспорта:
class Train < ActiveRecord::Base
  has_many :moveable_locations, as: :moveable, dependent: :destroy
  has_many :locations, through: :moveable_locations
end

Посмотреть код полностью

Параметр as тут является обязательным, он говорит классу Train о том, что связь полиморфная.
И сокращаем Location
class Location < ActiveRecord::Base
  has_many :moveable_locations, dependent: :destroy

  has_many :trains, :through => :moveable_locations
  has_many :ships, :through => :moveable_locations
  has_many :copters, :through => :moveable_locations
  has_many :trucks, :through => :moveable_locations
end

Запускаем тесты (ведь все пишут тесты для моделей, верно?) и… они не проходят.

Оптимальное решение


Дело в том, что тут еще нужно немного особой магии, которая объяснит классу Location соответствие ассоциаций (trains, ships etc) значениям в колонке moveable_type.
class Location < ActiveRecord::Base
  has_many :moveable_locations, dependent: :destroy

  with_options :through => :moveable_locations, :source => :moveable do |location|
    has_many :trains, source_type: 'Train'
    has_many :ships, source_type: 'Ship'
    has_many :copters, source_type: 'Copter'
    has_many :trucks, source_type: 'Truck'
  end
end

Блок with_options здесь всего лишь позволяет сократить количество кода и не писать :through => :moveable_locations, :source => :moveable после объявления каждой ассоциации.
source и source_type являются теми параметрами, которые магическим образом свяжут Location со всеми видами транспорта (я встречал утверждение, что source_type — это замена параметра class_name, но это не совсем верно, source_type используется только для полиморфных ассоциаций).
Теперь мы можем удобно работать с сущностями таким образом:
train = Train.new
train.locations << city1
train.locations << city2
train.locations << city3
copter = Copter.new
copter.locations << city1

И даже таким:
big_city = Location.new
big_city.trains << train1
big_city.trains << train2
big_city.copters << copter1
big_city.trucks << truck1
big_city.trucks << truck2

В итоге для реализации полиморфной транзитивной связи нам потребовалась только одна дополнительная таблица и одна дополнительная модель.
Посмотреть код полностью

P.S.:
Две строчки в видах транспорта:
  has_many :moveable_locations, as: :moveable, dependent: :destroy
  has_many :locations, through: :moveable_locations

являются общими для всех четырех классов, поэтому их можно убрать в общий подключаемый модуль
Tags:
Hubs:
+28
Comments 18
Comments Comments 18

Articles