One 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.