В статье идет речь о методе создания полиморфизма для связей 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
являются общими для всех четырех классов, поэтому их можно убрать в общий подключаемый модуль
Автор: paranoic