Arel merge — a hidden gem
Recently, in a moment of well-timed stubbornness, I came across arel’s ability to merge scopes. Merging greatly expands the ways in which you can apply scopes, and so too the kinds of logic you can put in them. As far as I can see, though, they’re not very widely used. In fact, the only place I’ve seen them referred to is in this excellent railscast by Ryan Bates. Well, here’s my sell.
An arel query’s starting point determines the result: you base each query on the model whose records you want in the results. Whether you’re joining or including, or supplying nested conditions, you get a list of user records by starting your query on User (or one of its scopes). This is a good pattern, but I’ve long wondered how to also involve scopes from other models.
If I wanted to employ a scope defined on a model I was joining to, then up until last week I thought I was up the creek, but this is what merge makes possible. To illustrate the solution, here’s a stylised portion of the data model that backs our publishing platform at The Conversation.
class User < ActiveRecord::Base
has_many :collaborations
has_many :articles, :through => :collaborations
end
User joins to other things like author profiles too, but those aren’t relevant here.
class Collaboration < ActiveRecord::Base
belongs_to :user
belongs_to :article
validates_inclusion_of :role, :in => %w[editor author]
def self.editorial
where(:role => "editor")
end
end
Collaboration represents a user’s relationship to an article. It has a role field describing that relationship, along with scopes for each type (I’ve shown just one).
class Article < ActiveRecord::Base
has_many :collaborations
has_many :users, :through => :collaborations
def self.drafting
where(:published_at => nil)
end
end
Article has a scope of its own, filtering to the articles currently being drafted.
To find all the admin users of a group, we want to start on the result model (User), even though the role information is stored on Collaboration. In the past, I would have written this query:
article.users.where(:collaborations => {:role => "editor"})
This produces nice SQL, but we had to duplicate that #where logic from Collaboration.editorial. Surely it’s better to keep things dry?
Collaboration.editorial.
where(:article_id => article.id).map(&:user)
Hey, at least we got to use our editorial scope, right?
Turns out, you can compose the proper query by re-using that scope, even though we’re quering it’s not defined on User.
article.users.merge(Collaboration.editorial)
#merge!
Combining queries like this (I believe the technical term is smooshing—the queries have been smooshed together) means you can re-use the scopes you have all over the place. Even better, merge lets you push much more query logic into scopes than you otherwise could. You win on two fronts: keeping your querying logic dry in this case means using your DB like a real DB, too.
SELECT "users".* FROM "users"
INNER JOIN "collaborations"
ON "users"."id" = "collaborations"."user_id"
WHERE "collaborations"."article_id" = 1
AND "collaborations"."role" = 'editor';
There’s one thing to be aware of here: when you start re-using scopes in this way, particularly across models, you run the risk of coupling your models. My feeling about this is that it’s not a problem as long as non-trivial scopes are specced, and that model-specific logic is wrapped up in a scope on that model. As long as a scope’s spec breaks when one of the scopes it depends on changes, I feel comfortable with this kind of declarative coupling.
Merging scopes works with associations, too: when you merge an association, you merge the condition that defines it, as well as the attached scopes.
class Figure < ActiveRecord::Base
belongs_to :article
end
Figure model, to represent the figures within articles.
Suppose we want to retrieve all the figures associated with the drafts that a given user is editing. Our constraints: we have to start on Figure because figures are what we want, and we want to pull in logic from Article and Collaboration scopes.
class User < ActiveRecord::Base
def draft_figures
Figure.joins(:article => :collaborations).
merge(Article.drafting).
merge(collaborations.editorial)
end
end
draft_figures are all the figures on unpublished articles, that the user is an editor of.
Note well: this isn’t a class-level scope, it’s a list of figures corresponding to a specific user. Even so, we started the query Figure-wide, and scoped it to the user by merging the collaborations association. That’s what narrows this query to the user in question.
SELECT "figures".* FROM "figures"
INNER JOIN "articles"
ON "articles"."id" = "figures"."article_id"
INNER JOIN "collaborations"
ON "collaborations"."article_id" = "articles"."id"
WHERE "articles"."published_at" IS NULL
AND "collaborations"."user_id" = 1
AND "collaborations"."role" = 'editor'
We’ve just recently done some refactoring across our codebase to employ merge, and I’ve been really pleased with how a lot of complex logic has fallen away. When you can re-use scopes anywhere, you have a lot more freedom to define them cleanly and simply.