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.
This would have taken way longer over twitter
http://ryanangilly.com/post/3207048558/stinky-magic