
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:
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 allunnecessary common fields.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:
We do rake db: migrate and ... and the last touch remains: we add connections to models.family ties by a polymorphic connection with a certain: content, and now any model in which we write
Now we are fully ready to go to the console and rejoice :)
Without a doubt, so that the content_type field is immediately filled with a value, we can write this:
Let's try to see all the topics:
Of course, the whole REST is available, you can not even doubt it;)
Now we will fill the controllers with code:
Immediately move on to the views, and the first is posts # index:
And now - perhaps the most interesting: adding new records. As you already noticed, in the previous listing there is a line
So that everything falls into place, I will give the code of the new method of the topics controller:
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!
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
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
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
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 :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 likemodule 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!
UPD:
Useful materials on the topic:
STI - one table and many models
Creating multi-model forms