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

    На днях в блоге 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 — одна таблица и много моделей
    Создание мульти-модельных форм
    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

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

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

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

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

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

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

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

                              А картинки… Ну, разве что код вместо текста картинками выложить, но за это меня возненавидят высшие силы:)
                            • +1
                              а можно еще указать получившуюся структуру базы данных после миграций?
                              • 0
                                Файл 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
                                Возможно, здесь было бы удобно использовать accepts_nested_attributes_for(*attr_names).
                                Подробности и примеры здесь: api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html.
                                • 0
                                  спасибо, посмотрю
                                  • 0
                                    я так понял, что представление остается таким же, меняется только контроллер?
                                  • 0
                                    я это уже предложил на несколько постов выше -)

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

                                    только кармы не хватает опубликовать, подожду немного)
                                    • 0
                                      мы тут щас дружно поднапряжемся, да попробуем поднять, ради такой-то статьи!
                                  • 0
                                    Ваши методы 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 пишутся аналогично.

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