Rails 3.0 – Arel Acrobatics

I’ve recently started a project, using Ruby on Rails version 3.0.1, that has been teaching me a *lot*. One such lesson is some pretty quirky magic involving rails’s new Arel engine for querying the database.  It’s best taught through an example modeling the exact problem I had – in a different problem space.

Assume you have a broad forum site, and you want to find the comments on posts only on forums belonging to the logged in user. In rails, the object structure for this would typically look something like this:

class Forum
 has_many :posts
end

class Post
 belongs_to :forum
 has_many :comments
end

class Comment
 belongs_to :post
end

At this point, the first step in logic is to get the forums belonging to the current user. In rails 3.0, with arel, this is incredibly easy. It would look something like this:

class Forum
 def self.mine
  where(:user_id => User.current.id)
 end
end

I make one assumption in this code – that there is an authentication system in place, and User.current gets the currently logged in user. The line making a call to “where” is the arel magic – a relational algebra engine that builds up a data structure representing a query, without building the actual query or executing it, until the last possible moment (such as actually cycling through the records found).

So, how would we get the posts across all of the current user’s forums? Well, here’s the first, easy solution:

class Post
 def self.mine
  joins(:forum).where(:forum => { :user_id => User.current.id })
 end
end

However, there are two problems with this code. 1) It’s starting to get less readable, and 2) It violates DRY: the where clause is essentially an exact duplicate of the where clause from the Forum version of this filter. So how can we fix this? Here’s the second version I came up with:

class Forum
 def self.posts
  Post.joins(:forum) & scoped
 end
end

class Post
 def self.mine
  Forum.mine.posts
 end
end

Now the acrobatics are beginning. First, the new scoping method in the Forum class: what this does is it uses a Forum’s relations to filter out posts – so you get some set of forums, say, through Forum.mine… and then you use that relation to grab all of the associated posts. However, you can’t just do that out of the box – because the relation Forum.mine is grabbing from the forums table, you have to merge it *into* a relation that grabs from the Posts table, and use a join on the forum table to apply the filters correctly. (e.g. Get all posts that are in forums belonging to the current user: Forum.mine.posts becomes Post.joins(:forum) & Forum.mine (scoped grabs the current relation) – which becomes Post.joins(:forum).where(:forums => { :user_id => User.current.id }) – because the where was performed on the forums table.

Great. That works, and it’s clean, and there’s no repetition. But what happens when we go after the comments? Let’s try the same thing:

class Post
 def self.comments
  Comment.joins(:post) & scoped
 end
end

class Comment
 def self.mine
  Post.mine.comments
 end
end

This is the most DRY way to try the same solution. Except, it doesn’t work. What it eventually translates into is Comment.joins(:post).joins(:forum).where(:forum => { :user_id => User.current.id }). The problem here is that this tries to join both the posts AND the forums DIRECTLY to the comments table. Forums have to be joined THROUGH posts. The correct arel would be built by this:

Comment.joins(:post => :forum).where(:forum => { :user_id => User.current.id })

This tells rails that forums can only be found by looking at posts. So, how do we do this DRY style? This is the one that took me a while to figure out, but here is the solution I came up with:

class ActiveRecord::Base
 def self.query_association(klass)
  rel = klass.joins(self.name.to_sym => scoped.joins_values)
  joins = rel.joins_values
  rel = rel & scoped
  rel.joins_values = joins
  rel
 end
end

class Forum
 def self.posts
  query_association Post
 end
end

class Post
 def self.comments
  query_association Comment
 end
end

class Comment
 def self.mine
  Post.mine.comments
 end
end

What’s that? I extended ActiveRecord::Base? You’re damn right, I did. (Well, I haven’t tried this exact solution – you may have to pass in the scoped value. I realized the Base extension improvement while writing this post, and wrote a class method to do this, instead.) What happens here, step by step, is this: First, the joins are built correctly – any joins that were used in the original relation are scoped to go *through* the new join. Then, we save the generated relation’s joins. This is important, because when we do the final merge, the bad joins will be added in – we don’t want this. Next, we perform the real merge, and restore our original, valid joins. In effect this gives us the following telescope of the code:

Comment.mine
Post.mine.comments
Forum.mine.posts.comments
Forum.where(:user_id => User.current.id).posts.comments
Forum.where(:forums => { :user_id => User.current.id).posts.comments (This is functionally equivalent to the previous line)
Post.joins(:forum).where(:forums => { :user_id => User.current.id }).comments
Comment.joins(:post => :forum).where(:forums => { :user_id => User.current_id })

Success! And the best thing about this? It’s 100% recursively scalable. You can use it with a single association or with a monster hierarchy of the form A -> B -> C -> D -> E -> F -> … DRY, recursive, scalable. Now that’s what I call magic.

Advertisements

One Response to Rails 3.0 – Arel Acrobatics

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: