Chris RhodenPolymorphic Associations and Abilities

Chris Rhoden posted on Monday, November 1st, 2010 | Rails, Ruby

I recently had a conversation with an old friend who is in the process of learning Ruby on Rails. He had run into a problem with polymorphic associations and I recommended he try something I had done for another project using polymorphic associations. It turns out that it was a good solution to the problem, and since I haven’t seen the pattern talked about elsewhere, I thought I should get it down on paper.

For the sake of example, let’s say we’re building a Tumblr clone, where the polymorphic association lives inside of a Post model, and the different kinds of posts are all models unto themselves. We could use single table inheritance, but maybe the models vary enough or the storage method differs enough that that’s a bad idea. For that matter, Single Table Inheritance often just feels wrong.

Simplicity will work best in this case, so let’s say that every post has a title, timestamps, and belongs to a user.

# db/migrate/001_create_posts.rb
class CreatePosts < ActiveRecord::Migration
  def self.up
    create_table :posts do |t|
      t.string :title
      t.references :user
      t.integer :postable_id
      t.string   :postable_type
      t.timestamps
    end
  end

  def self.down
    drop_table :posts
  end
end
# app/models/post.rb
class Post < ActiveRecord::Base
  belongs_to :user
  belongs_to :postable, :polymorphic => true
end

Before we even get into the different kinds of posts we might want to make, let’s abstract this model so that we can tack it onto nearly any model in our application. How? A little bit of metaprogramming.

# lib/postable.rb
module Postable
  def self.included(klass)
    klass.instance_eval do
      has_one :post, :as => :postable
      alias_method_chain :post, :autocreate

      [:title, :user, :created_at, :updated_at].each do |method|
        delegate method, "#{method}=", :to => :post
      end
    end
  end

  def post_with_autocreate
    post_without_autocreate || build_post
  end
end

What does this get us? Well, in just a few lines of code, we have saved ourselves from really having to think about this problem very hard again. Consider creating two kinds of posts, something like a quote post and a freeform text post (called a blob, in this example):

# db/migrate/002_create_quotes_and_blobs.rb
class CreateQuotesAndBlobs < ActiveRecord::Migration
  def self.up
    create_table :quotes do |t|
      t.string :quotee
      t.text :quote
    end
    create_table :blobs do |t|
      t.text :content
    end
  end

  def self.down
    drop_table :quotes
    drop_table :blobs
  end
end

I have deliberately chosen separate interfaces for these two kinds of model so I can show off some other stuff later. But to get them posting, all we need to do is include our “Postable” module and we are up and running:

# app/models/quote.rb
class Quote < ActiveRecord::Base
  include Postable
end

# app/models/blob.rb
class Blob < ActiveRecord::Base
  include Postable
end

That’s it. We can get started immediately by creating our forms. Because of the Postable interface we have created, both models will automatically have the fields on Post (and, in fact, will work transparently thanks to delegation). The only other thing we need to think about is creating a common interface back to the individual post types from the Post model. We can do this with a helper, the model, or we can take advantage of Rails’ partial rendering and just create a views/quote/_quote.html.erb and views/blob/_blob.html.erb. Those views might look something like:

# app/views/blob/_blob.html.erb
<h2>
  <%= blob.title %>
</h2>
<div class='byline'>
  by <%= link_to blob.user.name, blob.user %> on <%= blob.created_at %>
</div>
<div class='blob-content'>
  <%= blob.content %>
</div>

I’ll leave the quote partial as an exercise for the reader.

At this point, we can put all of our posts on a single page like this:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.order_by('created_at DESC')
  end
end

# app/views/posts/index.html.erb
<h1>All Posts</h1>
<%- @posts.each do |post| %>
  <%= render post %>
<% end -%>

The same technique can be used in a number of situations, such as many kinds of authenticable users with one login screen or some other sort of shared attributes. What’s nice is that it is trivially extensible. You create a model with any extra fields that you need, include your module, and you’re up and running.

8 Comments to Polymorphic Associations and Abilities

michael kirk
November 3, 2010

Hi Chris, I’ve been snooping around prx.org a good bit lately, after finding your apn_on_rails plugin on github.

I was happy to find this tutorial which did a lot to get me to use Ruby’s meta-programming faculties as a solution to a current problem.

I did have one problem with this though, the “post” method defined in your Postable module was not being called. Instead the “post” method added to the Quote class was being called.

This didn’t make sense to me, until banisterfield on #ruby explained to me:

21:33
When you “include ModuleName”, the module is put in the class’s inheritance chain, so methods on the module are resolved after methods on the class. If you remove the method on the class, then the method on the module will be exposed.

So, though “define post” in your example defines a method in the module, the “has_one :post” adds a method to the Quote class, which is further along the inheritance chain. By removing the method from the Quote class, we are able to see the method in the Postable module.


# lib/postable.rb
module Postable
def self.included(klass)
klass.instance_eval do
has_one :post, :as => :postable

remove_method :post #makes modules definition visible to class

[:title, :user, :created_at, :updated_at].each do |method|
delegate method, "#{method}=", :to => :post
end
end
end

def post
super || build_post
end
end

Also, consider this a request to allow comment previews on your blog. :)

michael kirk
November 3, 2010

Another update: My suggestion above isn’t working either…

It seems post.postable is continuously reassigned. I think that means that super.post (in lib/postable.rb#post) is always returning nil.

I don’t have time right now, but I’ll update when I figure it out.

FYI, I’m using Rails 2.1

chris
November 3, 2010

@michael – I’m a bit embarrassed – this code was executed, but not all at the same time. I’ll be updating the code from a real rails 2 and rails 3 project this evening.

I see where I went wrong, though, and I feel like a complete doofus. In a nutshell, you should hold off on including the module with the post method until *after* you have defined the association, and the self.included callback is called too late in the chain.

As I said, I’ll be updating the post to reflect reality here soon – and if you’re interested in more rails stuff, I’ll make sure to keep it coming – and I’ll keep the silly mistakes to a minimum from now on.

;-)

chris
November 3, 2010

Okay, the code as updated above works in both rails 2 and rails 3, though it may not be the most optimal way to do it. I find that I always fall back to alias_method_chain when the ruby object model gets too complicated.

michael kirk
November 12, 2010

alias_method_chain is great! Thanks for showing me. Things now work as expected.

michael kirk
November 15, 2010

Is there a way to push this abstraction further, so that joins are transparent?

E.g. Quote.find_by_user_id(5) tells me that the quotes table doesn’t have a column “user_id”

And to further complicate things, I’m actually querying off of a user’s email attribute.

e.g. Quote.find_by_users_email(“foo@example.net”)

I’m looking for the AR equivalent to this SQL:

select quotes.* from quotes, posts, users
where quotes.id = posts.postable_id
and posts.postable_type = “Quote”
and posts.user_id = users.id
and users.email = “foo@example.net”

I’ll keep looking…

michael kirk
November 15, 2010

I guess what threw me off, is the Quote only has one user (via it’s one post), but it seems like you cannot ‘has_one’ through an intermediate table.

So my solution, was to include this in lib/postable.rb

has_many :users, :through => :post

then:

Quote.find(:first, :joins => [:post, :users], :conditions => {‘users.email’ => “foo@example.net”})

chris
November 15, 2010

@michael Good point! If you are using a recent version of rails, you can also include a default scope that does the joins for you automatically by default, which I highly recommend.

Leave a comment

Support Us!

PRX

Categories & projects

Archives