Ruby on Rails: Extending ActiveRecord::Base to define your own ActiveRecord association methods
Posted: - Modified: | development, geek, rails, workOne of the things I really like about Rails is the ability to add to existing classes so that your code can be cleaner. For example, in the app we’re working on, I need to be able to display a list of offers associated with an organization. I also need to filter that list of offers by different criteria. If the user is not in tutorial mode, I need to filter out any tutorial-related offers. I want to show offers with different workflow states, too.
If I had to do this in straight SQL, I would need to write many queries to cover the different cases, or write my own query-building engine that takes conditions into account. In the Drupal world, I might try to build a View with lots of arguments, and then use a views_pre_execute
hook to monkey around with the generated SQL.
In the Rails world, things are much simpler. I started off by chaining queries, because you can add conditions to the end of an ActiveRelation and go from there. That gave me code that looked like this:
base = Offer.includes(:donation).where("organization_id = ? AND (donations.deadline IS null OR donations.deadline >= ?) AND (NOT (offers.workflow_state IN (?, ?, ?)))", @organization.id, Time.now, Offer::DRAFT, Offer::ALLOCATED, Offer::CONFIRMED).order('offers.deadline') @direct_offers = base.where("offers.workflow = ?", Donation::DIRECT) @open_offers = base.where("offers.workflow = ?", Donation::OPEN)
Then I asked myself: How can I make this code even cleaner? I thought about adding instance methods. For example, in my Organization class, I could define the following:
class Organization # Other stuff goes here def current_offers self.offers.includes(:donation).where("(donations.deadline IS null OR donations.deadline >= ?) AND (NOT (offers.workflow_state IN (?, ?, ?)))", Time.now, Offer::DRAFT, Offer::ALLOCATED, Offer::CONFIRMED).order('offers.deadline') end def current_offers_by_workflow(workflow) self.current_offers.where("offers.workflow = ?", Donation::OPEN) end end
That would allow me to replace the code above with something like this:
@direct_offers = @organization.current_offers_by_workflow(Donation::DIRECT) @open_offers = @organization.current_offers_by_workflow(Donation::OPEN)
… so if I wanted to filter out tutorial entries, I could do that in def current_offers
by adding a where clause for the tutorial column.
But it seemed clunky to have to specify all these instance methods in order to filter by different ways. What I really wanted was to be able to chain my custom filters together, so that I could write code like this:
@direct_offers = @organization.offers.filter(current_user).direct @open_offers = @organization.offers.filter(current_user).open
and then eventually be able to do things like:
list = @organization.offers.filter(current_user).current.direct.allocated
(If I really wanted to.)
I couldn’t figure out where to add the methods so that they’d be defined in the right place. If I added the methods to the Organization class, they couldn’t be called on the ActiveRecord relations. A little bit of searching, and I figured out how to do it in Rails. It turns out that you can extend ActiveRecord relations with your own methods! Here’s how.
You’ll need to extend ActiveRecord::Base with your own methods. I put this in config/initializers/activerecord_extensions.rb
.
module ProjectNameActiveRecordExtensions def filter(control) exclude_tutorial = true # Include the tutorial offers for users in tutorial mode if control.is_a? User and control.tutorial exclude_tutorial = false # You can also pass filter(false) to turn off these filters for testing elsif !control exclude_tutorial = false end if exclude_tutorial scoped.joins(:donation).where('donations.tutorial=?', false) else scoped end end # other methods go here... end ActiveRecord::Base.extend ProjectNameActiveRecordExtensions
The trickiest part was figuring out how to do a conditional filter, and that’s what scoped
is for. I wanted to include the tutorials if the user was in tutorial mode, so my function should be a pass-through in that case. I couldn’t return self or nil, because that broke the associations. scoped
turned out to be the magic keyword that refers to the current scope of the query.
What if you want to use the same words in different contexts? For example, “pending” might need to result in two different queries depending on whether you’re asking for pending offers or pending requests. ActiveRecord::Base
is used for all classes, but you can use self
to find out what class is being used for scoping. For example:
def pending if self == StandingRequest scoped.where("standing_requests.workflow_state=?", StandingRequest::PENDING) else # Replace with other cases as I find the need for them raise "Undefined behaviour" end end
I love the fact that Rails lets you modify so much in order to make building sites easier. It’s like Emacs for the Web, and it makes my brain happy.