Polymorphic bonds

    The other day , an article on polymorphic relationships appeared in the Ruby on Rails blog , in which the author wrote all sorts of different things, but forgot to mention how to use them and why they are needed (then, of course, he corrected, but he wrote quite superficially anyway). At first, I was even afraid that this article of mine in some incomprehensible way escaped from the “draft” and fell into the general feed. Then he figured it out, gathered his thoughts, and decided to finish writing his own. What are polymorphic bonds and why are they needed? In one of his screencasts, Ryan Bates already talked about this, and by no means do I want to tell the same thing. The situation was as follows:


    We have models Articles , Photos and Events . And there is also a Comments model . And I really want to keep all comments (comments of articles, photos and events) in one table.
    There are a lot of articles on this problem on the Internet, but there are also cases “on the contrary”. It is not necessary to go far, let's try to develop the functionality of the Habrahabr posts!

    Here we can write articles, while the articles themselves can be of different types: Topic, Link, Question, Podcast, Translation and Vacancy. But you must admit, it would be rather stupid to create six separate tables containing almost the same fields: heading, date of creation / change, various seo information, tags, status (published / not) and something else.

    I wanted to paint everything “for the smallest ones”, but around the middle of the article I realized that somehow it works out a lot (and polymorphic connections are used mostly by no means for beginners). And yes, I completely forgot: let's trim our functionality a bit and design connections based on 3 models: Topic, Link, and Podcast (otherwise it will be too much code, but everything will be done the same.

    So, let's go!
    Create 4 models: Post, Topic , Link and Podcast: The incomprehensible Post model will be the “parent” for the rest and, in fact, will contain all unnecessary common fields.
    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
    As you can see, links and podcasts have the same fields, let's make one more polymorphic relationship :)

    When creating the Post migration, we specified common fields for all other tables (in this case, the title, title of the article (published) and date of creation / change (they appended automatically)). In addition, there are 2 fields in which the identifier of the element (content_id) and the model to which this element belongs (content_type will be stored; we will deal with this a bit later).
    We will not touch the Post model migration anymore, but in all other migrations we will delete this line:
    t.timestamps
    After all, the created_at and updated_at fields (which the timestamps helper generates) are now one at all - in the posts table.

    We do rake db: migrate and ... and the last touch remains: we add connections to models.
    # 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
    Instead of writing “has_many: links (: topics,: podcasts)” in the Post model, we say that Post is tightly bound by family ties by a polymorphic connection with a certain: content, and now any model in which we write
    :has_one :post, :as => content
    will become a subsidiary of our Post. What we, in fact, have done above.
    Now we are fully ready to go to the console and rejoice :)
    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
    We created a new topic (specifying only the body), saved, created a new post (specifying the title, status and content itself (you could write: content_id => t.id,: content_type => t.class (as if implying also .to_s)).

    Without a doubt, so that the content_type field is immediately filled with a value, we can write this:
    >> Post.topics.new
    => #

    Let's try to see all the topics:
    >> Post.find_all_by_content_type("Topic")
    I agree, uncomfortable; let's add some named_scope'ov to the Post model:
    named_scope :topics, :conditions => { :content_type => "Topic" }
    named_scope :links, :conditions => { :content_type => "Link" }
    named_scope :podcasts, :conditions => { :content_type => "Podcast" }
    We go back to the console, do reload! and look around:
    >> Post.topics
    >> Post.links
    >> Post.podcasts
    Now you need to understand how to access all the properties of our posts.
    >> p.body
    NoMethodError: undefined method `body' for #
    >> t.title
    NoMethodError: undefined method `title' for #
    It turns out that it’s not so :) Let's try something like this:
    >> p.content.body
    => "Just one more test topic body here"
    >> t.post.title
    => "Some test title"
    Played and enough, it's time to do the controllers (and there is not far from the presentation). We leave the rail console, we are waiting for the usual :)
    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
    Next, go to config / routes.rb and put it in this form:
    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
    Now, let's start the server and see what we get:bash-3.2$ script/server
    And we got this:
    /posts (здесь будет список всех постов)
    /posts/topics (здесь — только посты–топики)
    /posts/links (а тут — только посты–ссылки)
    /posts/podcasts (и вы никогда не угадаете, что же будет тут ;)

    Of course, the whole REST is available, you can not even doubt it;)

    Now we will fill the controllers with code:
    # 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
    In posts_controller, for now, we’ll only populate index, show is not needed there. In the rest, we fill in both index (there, as you can see, only the “necessary” posts will be displayed) and show (and here the article / link / podcast itself will be displayed). I think we can do without explanation here, we already wrote all this code in the console.

    Immediately move on to the views, and the first is posts # index:

    <% @posts.each do |post| %>
      <%= link_to post.content.class.to_s.pluralize, "/posts/#{post.content.class.to_s.downcase.pluralize}" %> →
      <%= link_to post.title, "/posts/#{post.content.class.to_s.downcase.pluralize}/#{post.id}" %>

    <% end %>
    At first, I wrote just like that, because in the view, tons of ifs are even worse (IMHO). Then I felt ashamed that people would see such horror on Habré, and decided to make this horror a little less terrible. So, open app / helpers / posts_helper.rb and write something like
    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
    Now we have 2 methods: posts_smth_path and posts_smths_path, which are a special case of posts_topic_path and posts_topics_path (instead of topic / topics, of course, there may also be link / links and podcast / podcasts). Having done the work on the errors, we look at what happened:

    <% @posts.each do |post| %>
      <%= link_to post.content.class.to_s.pluralize, posts_smths_path(post) %> →
      <%= link_to post.title, posts_smth_path(post) %>

    <% end %>
    I think for a draft is enough. Now the rest of the views:

    <% @posts.each do |post| %>
      <%= link_to post.title, posts_topic_path(post) %>

    <% end %>


      <%= link_to "Add new Topic", new_posts_topic_path %>


    This is the index method, and with the exception of the posts_topic_path and new_posts_topic_path methods, it is the same everywhere, it makes no sense to block a ton of code here. The other two will have posts_link_path / new_posts_link_path and posts_podcast_path / new_posts_podcast_path respectively.

    <%= @post.title %>


    <%= @post.content.body %>
    And this is show, and in this example it’s the same everywhere :)

    And now - perhaps the most interesting: adding new records. As you already noticed, in the previous listing there is a line
    <%= link_to "Add new Topic", new_posts_topic_path %>
    The link_to helper will generate a link, when clicked, we will be taken to the / posts / topics / new page, so we just need to create the file app / views / posts / topics / new.html.erb and write something like this:
    new.html.erb -->
    <% form_for [:posts, @topic] do |form| %>
      <% form.fields_for @post do |p| %>
        


          <%= p.label :title %>

          <%= p.text_field :title %>
        


        


          <%= p.check_box :published %>
          <%= p.label :published %>
        


      <% end %>
     
      


        <%= form.label :body %>

        <%= form.text_area :body %>
      


     
      

    <%= form.submit "Create" %>


    <% end %>
    I’ll make a reservation right away, so far we will only talk about topics, in other controllers / views there will be a similar code.

    So that everything falls into place, I will give the code of the new method of the topics controller:
    def new
      @topic = Topic.new
      @post = Post.topics.new
    end
    And, for clarity, I will repeat the code that we wrote in the routes.rb file:
    map.namespace(:posts) do |post|
      post.resources :topics, :links, :podcasts
    end
    Once upon a time we defined namespace, and now when creating forms for topics instead of form_for topic do ... we will specify our namespace, that is, write form_for [: posts, topic ] do ... (similarly for links and podcasts).
    At the very end of the form we put the body field and the submit button of the form, and before that we use the fields_for helper, which is similar in behavior to the form_for helper, except that it creates form tags. Thus, we get, as it were, 2 forms, while one is embedded in the other.

    Fill out the form, click the Create button and go to the create method of the topics controller. We’ll write something working in it, and the article is ready to add!
    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
    I sincerely apologize for such an abundance of code in the method, I am sure that this code can (and must be!) Be put into the model, but I don’t know how. I hope one of the more experienced comrades corrects me.

    That's all, I think. Updating the elements is done similarly to the creation, with this there should be no difficulties. I apologize for the mistakes, typos, tediousness and size of the article: I did not want to!

    Treat with all severity, this is not my first article on Habr!

    UPD:
    Useful materials on the topic:
    STI - one table and many models
    Creating multi-model forms

    Also popular now: