Полиморфные связи

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

Что же такое полиморфные связи и для чего они нужны? В одном из своих скринкастов Ryan Bates уже рассказывал об этом, и я ни в коем случае не хочу рассказывать то же самое. Ситуация была следующей:
у нас есть модели Статьи, Фотографии и События. А еще есть модель Комментарии. А еще очень хочется все комментарии (комментарии статей, фотографий и событий) хранить в одной таблице.
Статей по этой проблеме в интернете очень много, но бывают и случаи «наоборот». Далеко ходить не нужно, давайте попробуем разработать функционал постов Хабрахабра!

Здесь мы можем писать статьи, при этом сами статьи могут быть разных типов: Топик, Ссылка, Вопрос, Подкаст, Перевод и Вакансия. Но согласитесь, было бы довольно глупо создавать шесть отдельных таблиц, содержащих практически одинаковые поля: заголовок, дата создания/изменения, разного рода сео-информация, теги, статус (опубликовано/нет) и обязательно что-нибудь еще.

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

Итак, поехали!
Создадим 4 модели: Post, Topic, Link и Podcast. Непонятная модель Post как раз и будет «родительской» для остальных и, собственно, будет содержать все ненужные общие поля.
bash-3.2$ script/generate model post title:string published:boolean content_id:integer content_type:string
bash-3.2$ script/generate model topic body:text
bash-3.2$ script/generate model podcast link:string description:text
bash-3.2$ script/generate model link link:string description:text
Как видите, ссылки и подкасты имеют одинаковые поля, давайте сделаем еще одну полиморфную связь :)

При создании миграции Post мы указали общие поля для всех остальных таблиц (в данном случае это заголовок (title), статус статьи (published) и дату создания/изменения(они дописались автоматически)). Кроме того, есть 2 поля, в которых будут храниться идентификатор элемента (content_id) и модель, которой принадлежит данный элемент (content_type; мы разберемся с этим чуть позже).
Миграцию модели Post трогать больше не будем, а вот во всех остальных миграциях удалим эту строку:
t.timestamps
Ведь поля created_at и updated_at (которые генерирует хелпер timestamps) теперь одни на всех — в таблице posts.

Делаем rake db:migrate и… и остается последний штрих: добавляем связи моделям.
# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :content, :polymorphic => true, :dependent => :destroy
end
 
# app/models/topic.rb
class Topic < ActiveRecord::Base
  has_one :post, :as => :content, :dependent => :destroy
end
 
# app/models/link.rb
class Link < ActiveRecord::Base
  has_one :post, :as => :content, :dependent => :destroy
end
 
# app/models/podcast.rb
class Podcast < ActiveRecord::Base
  has_one :post, :as => :content, :dependent => :destroy
end
Вместо того, чтобы в модели Post писать «has_many :links (:topics, :podcasts)», мы говорим, что Post крепко-накрепко связан семейными узами полиморфной связью с неким :content, при этом теперь любая модель, в которой мы напишем
:has_one :post, :as => content
станет дочерней для нашего Post'a. Что мы, собственно, и проделали выше.
Теперь мы полностью готовы перейти в консоль и возрадоваться:)
bash-3.2$ script/console
>> t = Topic.new(:body => "Just one more test topic body here")
>> t.save
>> p = Post.new(:title => "Some test title", :published => true, :content => t)
>> p.save
Создали новый топик (указав только тело), сохранили, создали новый пост (указав заголовок, статус и сам контент (можно было написать и :content_id => t.id, :content_type => t.class (как бы подразумевая при этом еще и .to_s)).

Вне всякого сомнения, чтобы поле content_type сразу заполнялось значением, мы можем написать так:
>> Post.topics.new
=> #<Post id: nil, title: nil, published: nil, content_id: nil, content_type: "Topic", created_at: nil, updated_at: nil>

Попробуем просмотреть все топики:
>> Post.find_all_by_content_type("Topic")
Согласен, неудобно; давайте добавим немного named_scope'ов в модель Post:
named_scope :topics, :conditions => { :content_type => "Topic" }
named_scope :links, :conditions => { :content_type => "Link" }
named_scope :podcasts, :conditions => { :content_type => "Podcast" }
Заходим опять в консоль, делаем reload! и осматриваемся:
>> Post.topics
>> Post.links
>> Post.podcasts
Теперь нужно понять, как получать доступ ко всем свойствам наших постов.
>> p.body
NoMethodError: undefined method `body' for #<Post:0x2653e00>
>> t.title
NoMethodError: undefined method `title' for #<Topic id: 8, body: "Just one more test topic body here">
Оказывается, что не так:) Попробуем что-нибудь вроде этого:
>> p.content.body
=> "Just one more test topic body here"
>> t.post.title
=> "Some test title"
Поигрались и хватит, пора делать контроллеры (а там и до представления недалеко). Выходим из рельсо-консоли, нас ждет обычная:)
bash-3.2$ script/generate controller posts index
bash-3.2$ script/generate controller posts/topics index show
bash-3.2$ script/generate controller posts/podcasts index show
bash-3.2$ script/generate controller posts/links index show
bash-3.2$ script/generate controller home index
Дальше идем в config/routes.rb и приводим его в такой вид:
ActionController::Routing::Routes.draw do |map|
  map.root :controller => 'home'
 
  map.namespace(:posts) do |post|
    post.resources :topics, :links, :podcasts
  end
  map.resources :posts
 
  map.connect ':controller/:action/:id'
  map.connect ':controller/:action/:id.:format'
end
А теперь запустим сервер и посмотрим, что у нас получилось:bash-3.2$ script/server
А получилось у нас вот что:
/posts (здесь будет список всех постов)
/posts/topics (здесь — только посты–топики)
/posts/links (а тут — только посты–ссылки)
/posts/podcasts (и вы никогда не угадаете, что же будет тут ;)

Само собой, доступен весь REST, в нем можно даже не сомневаться ;)

Теперь заполним кодом контроллеры:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.find(:all)
  end
end
 
# app/controllers/posts/topics_controller.rb
class Posts::TopicsController < ApplicationController
  def index
    @posts = Post.topics.find(:all)
  end
 
  def show
    @post = Post.topics.find(params[:id])
  end
end
 
# app/controllers/posts/links_controller.rb
class Posts::LinksController < ApplicationController
  def index
    @posts = Post.links.find(:all)
  end
 
  def show
    @post = Post.links.find(params[:id])
  end
end
 
# app/controllers/posts/podcasts_controller.rb
class Posts::PodcastsController < ApplicationController
  def index
    @posts = Post.podcasts.find(:all)
  end
 
  def show
    @post = Post.podcasts.find(params[:id])
  end
end
В posts_controller пока что заполним только index, show там не нужен. В остальных заполняем и index (там, как видно, будут отображаться только «нужные» посты), и show (а здесь будет отображаться сама статья/ссылка/подкаст). Думаю, тут можно обойтись без пояснений, весь этот код мы уже писали в консоли.

Сразу перейдем к представлениям, и первое — posts#index:
<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
  <%= link_to post.content.class.to_s.pluralize, "/posts/#{post.content.class.to_s.downcase.pluralize}" %> &rarr;
  <%= link_to post.title, "/posts/#{post.content.class.to_s.downcase.pluralize}/#{post.id}" %><br/>
<% end %>
Сначала я написал именно так, потому что городить в представлении тонны if'ов — еще хуже (имхо). Потом мне стало стыдно, что на Хабре люди увидят такой ужас, и решил сделать этот ужас чуть менее ужасным. Итак, открываем app/helpers/posts_helper.rb и пишем в него что-нибудь вроде
module PostsHelper
  def posts_smth_path(post)
    case post.content.class.to_s.downcase
      when "topic" : posts_topic_path(post)
      when "link" : posts_link_path(post)
      when "podcast" : posts_podcast_path(post)
    end
  end
 
  def posts_smths_path(post)
    case post.content.class.to_s.downcase
      when "topic" : posts_topics_path
      when "link" : posts_links_path
      when "podcast" : posts_podcasts_path
    end
  end
end
Теперь у нас есть 2 метода: posts_smth_path и posts_smths_path, которые являются частным случаем posts_topic_path и posts_topics_path (вместо topic/topics, само собой, там может быть еще link/links и podcast/podcasts). Сделав работу над ошибками, смотрим что у нас получилось:
<!-- app/views/posts/index.html.erb -->
<% @posts.each do |post| %>
  <%= link_to post.content.class.to_s.pluralize, posts_smths_path(post) %> &rarr;
  <%= link_to post.title, posts_smth_path(post) %><br/>
<% end %>
Думаю, для черновика вполне достаточно. Теперь остальные представления:
<!-- app/views/posts/topics/index.html.erb -->
<% @posts.each do |post| %>
  <%= link_to post.title, posts_topic_path(post) %><br/>
<% end %>
<p>
  <%= link_to "Add new Topic", new_posts_topic_path %>
</p>
Это метод index, и за исключением методов posts_topic_path и new_posts_topic_path он везде одинаковый, нет смысла городить тут еще тонну кода. В двух других будет posts_link_path/new_posts_link_path и posts_podcast_path/new_posts_podcast_path соответственно.
<h1><%= @post.title %></h1>
<%= @post.content.body %>
А это show, и в данном примере он вообще везде одинаковый :)

А теперь — самое, пожалуй, интересное: добавление новый записей. Как вы уже заметили, в предыдущем листинге присутствует строка
<%= link_to "Add new Topic", new_posts_topic_path %>
Хелпер link_to сгенерирует ссылку, при нажатии на которую мы перейдем на страницу /posts/topics/new, поэтому нам просто жизненно необходимо создать файл app/views/posts/topics/new.html.erb и написать в него что-нибудь такое:
<!-- app/views/posts/topics/new.html.erb -->
<% form_for [:posts, @topic] do |form| %>
  <% form.fields_for @post do |p| %>
    <p>
      <%= p.label :title %><br/>
      <%= p.text_field :title %>
    </p>
    <p>
      <%= p.check_box :published %>
      <%= p.label :published %>
    </p>
  <% end %>
 
  <p>
    <%= form.label :body %><br/>
    <%= form.text_area :body %>
  </p>
 
  <p><%= form.submit "Create" %></p>
<% end %>
Сразу оговорюсь, пока что речь будет идти только про топики, в остальных контроллерах/представлениях будет аналогичный код.

Чтобы все встало на свои места, я приведу код метода new контроллера topics:
def new
  @topic = Topic.new
  @post = Post.topics.new
end
И, для полной ясности, я повторю код, который мы писали в файле routes.rb:
map.namespace(:posts) do |post|
  post.resources :topics, :links, :podcasts
end
Когда-то давно мы определили namespace, и теперь при создании форм для топиков вместо form_for @topic do… мы будем указывать наш namespace, то есть писать form_for [:posts, @topic] do… (аналогично для ссылок и подкастов).
В самом конце формы мы помещаем поле body и кнопку submit'a формы, а перед этим пользуемся хелпером fields_for, который аналогичен по поведению хелперу form_for, разве что не создает теги формы. Таким образом у нас получается как бы 2 формы, при этом одна вложена в другую.

Заполняем форму, нажимаем кнопку Create и переходим к методу create контроллера topics. Напишем в него что-нибудь работающее, и добавление статей готово!
def create
  @topic = Topic.new(:body => params[:topic][:body])
  if @topic.save
    @post = Post.new({ :content => @topic }.merge params[:topic][:post])
    if @post.save
      redirect_to root_url
    else
      render :new
    end
  else
    render :new
  end
end
Я искренне извиняюсь за такое обилие кода в методе, я уверен что этот код можно (и нужно!) вынести в модель, но я так не умею. Надеюсь, кто-нибудь из более опытных товарищей меня поправит.

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

Отнеситесь со всей строгостью, это уже не первая моя статья на Хабре!

UPD:
Полезные материалы по теме:
STI — одна таблица и много моделей
Создание мульти-модельных форм
+40
28 декабря 2009, 12:39
45
ognevsky 5,1

комментарии (37)

–5
coldFlame #
А зачем в статье про полиморфные связи описывать script/generate и элементарные контроллеры?
+2
ognevsky #
На данный момент статья висит на главной (на самом верху!) около 30 минут. За это время появился 1 комментарий.
Это, видимо, говорит о том, что никто ничего не понял. Если убрать отсюда половину «лишнего», для большинства вся статья превратится в непонятный поток слов.
Не настолько сложно знающему человеку пролистать тривиальный код вниз, сколь сложно незнающему этот код выдумать.
НЛО прилетело и опубликовало эту надпись здесь
–4
Davert #
ну мне, как программисту на пэхапэ, пллиморфные связи понятны, и юзаю их достаточно часто. Вопрос стоит ли перечитывть ради этого статью напичканную руби-кодом и вызовом генераторов…
+1
ognevsky #
Было бы глупо помещать в блог «Ruby on Rails» статью, руби-кодом и вызовом генераторов не напичканную :)
–1
Davert #
Ага, с вещами, которы и так каждый руби-реилс-программист знает ) Я на рельсах тоже писал, потому Америку статья про полиморфные связи мне не откроет. Но могла б заинтересоватьлюдей, которые пишут н адругих языках. Концепт ведь не зависит от языка программирования…
+4
ognevsky #
Ваши комментарии напоминают по стилю: «у меня был мак, но макос говно, поэтому я теперь опять на винду перешел».
Писать на рельсах, знать (и понимать) все, о чем написано выше и после этого писать на пхп? Что-то вы явно недоговариваете :)
0
VolCh #
руби для души, пхп для денег — такой вариант даже не рассматриваете?
+2
ognevsky #
год—полтора назад рассматривал бы. сейчас на руби можно найти (практически без труда) хорошую работу.
+1
Vizakenjack #
автор писал статью для тех, кто изучает Ruby и (возможно) веб-программирование в целом.

лично мне было интересно прочитать ее, она написана понятным языком и с хорошими примерами, и правильно ответила на изначальный вопрос «что такое полиморфные связи и как их использовать в rails»
НЛО прилетело и опубликовало эту надпись здесь
+2
victorI #
поддерживаю

иногда, чтобы понять, что как работает, важно создать рабочий пример, погонять его, попробовать что-то поменять, подебажить. В статье есть всё для этого необходимое. Если бы этого тривиального кода не было, часть желающих попробовать просто отсеялась
+2
veevtam #
Полезная статья. У меня по-поводу этого примера есть только один вопрос: как элегантно избавиться от работы с моделью определенного типа контента в контроллере?

Насколько я понимаю идеологию рельсы (я в ней новичок) — один контроллер должен работать с одной моделью, а основная модель в данном случае — Post.
+1
ognevsky #
Не всегда получается так, как задумано в рельсах. Ведь если мне на определенной странице нужно показать одновременно и новости, и статьи, то мне и придется из контроллера обратиться к двум моделям (если не использовать новомодные презентеры/ячейки, но до этого я еще не дорос)
0
ptzn #
Отлично!
+1
rwz #
Некрасиво получается, что для создания модели надо трогать два класса — саму модель и Post. В этом ключе техника STI выглядит намного привлекательнее и удобнее.
0
ognevsky #
Много раз уже слышал про STI, так никогда и не читал. Теперь обязательно посмотрю повнимательнее в эту сторону, спасибо
+1
ptzn #
Там все просто как два пальца :) В таблицу добавляется поле type, а для моделей используется обычное наследование.
0
zed_0xff #
поддерживаю. как раз данный пример выглядел бы более элегантно с использованием STI.
а в качестве примера для полиморфизма можно придумать, например, приложение для комплектации компа для сборщиков.
Начать ее с «Computer has_many :parts», ну и т.д. :)
полиморфизм будет в том что Parts это могут быть CPU, MemoryModule, VideoCard и т.п.
0
ognevsky #
Хорошая идея, будет время — обязательно узнаю, что же это за STI такой, да может и напишу такую статью:)
+1
ptzn #
Так тут уже дело не в красиво/некрасиво, а что больше подходит для конкретной задачи. STI хорошо ложиться на логику, в случае когда модели совершенно не различаются, либо различаются незначительно. Ну и, конечно же, если есть желание за счет избыточности увеличить скорость работы, но это совсем другая история.
+1
aokolovich #
Статья хорошая, довольно просто получилось рассказать о непростом для понимания вопросе. Мне как начинающему рельсовику подобных статей не хватает.
P.S. Можно было даже фото с рубином в титьках вставить.
0
ognevsky #
Спасибо, скоро будет еще несколько статей (я надеюсь)
+1
Vizakenjack #
А еще можно в дополнение к статье добавить про nested attributes, которые появились в Rails 2.3

Они упрощают работу с формами, в которых используются несколько моделей.

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

Хоть это и не совсем касается полиморфных связей, но также будет полезно тем кого интересуют связи между моделями
0
ognevsky #
Я думаю, что никто не будет против примеров, каким бы ни был размер коммнтария:)
+1
feligz #
Можно было бы сделать скринкаст ну и картинки добавить, для наглядности, для новичков. Получилось бы более компактней и опять же наглядней.
0
ognevsky #
Из всех скринкастов, которые я когда-либо видел, только railscasts от Ryan Bates я могу смотреть. Остальные — неперевариваемый бред (долгие паузы, мэ'кание постоянное). Я не спешу пополнять список говно-скринкастеров:)

А картинки… Ну, разве что код вместо текста картинками выложить, но за это меня возненавидят высшие силы:)
+1
OmeZ #
а можно еще указать получившуюся структуру базы данных после миграций?
0
ognevsky #
Файл schema.rb получился такой:
create_table "posts", :force => true do |t|
  t.string "title"
  t.boolean "published"
  t.integer "content_id"
  t.string "content_type"
  t.datetime "created_at"
  t.datetime "updated_at"
end
 
create_table "topics", :force => true do |t|
  t.text "body"
end
 
create_table "links", :force => true do |t|
  t.string "link"
  t.text "description"
end
 
create_table "podcasts", :force => true do |t|
  t.string "link"
  t.text "description"
end
Собственно, в бд структура такая же.
+1
Alexander_N #
Возможно, здесь было бы удобно использовать accepts_nested_attributes_for(*attr_names).
Подробности и примеры здесь: api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html.
0
ognevsky #
спасибо, посмотрю
0
ognevsky #
я так понял, что представление остается таким же, меняется только контроллер?
0
Alexander_N #
Если вы про формы, и действия по созданию, то наоборот.
Контроллер останется прежним, а во вью можно использовать api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#M001605, — есть примеры для данных случаев Nested Attributes Examples.
0
ognevsky #
а, блин, запутался:)
спасибо большое!
0
Vizakenjack #
я это уже предложил на несколько постов выше -)

Вот даже написал статью: vizakenjack.habrahabr.ru/blog/79595/

только кармы не хватает опубликовать, подожду немного)
0
ognevsky #
мы тут щас дружно поднапряжемся, да попробуем поднять, ради такой-то статьи!
0
alexandrov #
Ваши методы posts_smth_path(post) и posts_smths_path(post) можно заменить вот такими методами в хелпере:

def edit_posts_content_path(content)
    eval("edit_posts_#{content.class.to_s.downcase}_path(content)")
end


Это метод возвращающий путь для edit. Так, если content это, например, Topic, то будет возвращено значение edit_posts_topic_path(content).

Методы для show и index пишутся аналогично.

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