Pull to refresh

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

Reading time 10 min
Views 10K
На днях в блоге 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<br/>class Post < ActiveRecord::Base<br/>  belongs_to :content, :polymorphic => true, :dependent => :destroy<br/>end<br/> <br/># app/models/topic.rb<br/>class Topic < ActiveRecord::Base<br/>  has_one :post, :as => :content, :dependent => :destroy<br/>end<br/> <br/># app/models/link.rb<br/>class Link < ActiveRecord::Base<br/>  has_one :post, :as => :content, :dependent => :destroy<br/>end<br/> <br/># app/models/podcast.rb<br/>class Podcast < ActiveRecord::Base<br/>  has_one :post, :as => :content, :dependent => :destroy<br/>end <br/>
Вместо того, чтобы в модели 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" }<br/>named_scope :links, :conditions => { :content_type => "Link" }<br/>named_scope :podcasts, :conditions => { :content_type => "Podcast" } <br/>
Заходим опять в консоль, делаем 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|<br/>  map.root :controller => 'home'<br/> <br/>  map.namespace(:posts) do |post|<br/>    post.resources :topics, :links, :podcasts<br/>  end<br/>  map.resources :posts<br/> <br/>  map.connect ':controller/:action/:id'<br/>  map.connect ':controller/:action/:id.:format'<br/>end <br/>
А теперь запустим сервер и посмотрим, что у нас получилось:bash-3.2$ script/server
А получилось у нас вот что:
/posts (здесь будет список всех постов)
/posts/topics (здесь — только посты–топики)
/posts/links (а тут — только посты–ссылки)
/posts/podcasts (и вы никогда не угадаете, что же будет тут ;)

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

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

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

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

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

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

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

UPD:
Полезные материалы по теме:
STI — одна таблица и много моделей
Создание мульти-модельных форм
Tags:
Hubs:
+40
Comments 37
Comments Comments 37

Articles