Sonntag, 6. April 2014

Scope the model!

The Ruby on Rails ORM ActiveRecord encapsulates all the inconvenient SQL stuff which keeps the code maintainable and secure (think of SQL injection).
For example the model:
class Person < ActiveRecord::Base
  attr_accessible :name, :birthday
end
and the PeopleController#index lists the first 10 kids:
class PeopleController < ApplicationController
  def index
    @people = if params[:page] and params[:page] > 0
      Person.where(:birthday => 18.year.ago..Time.current).limit(10).offset(params[:page] * 10).order(:name)
    else
      Person.where(:birthday => 18.year.ago..Time.current).order(:name)
    end
  end
end
The query does not limits the result, if no params[:page] was sent. Of course the generated query works, but the code is awkward. In addition new filter requirements will shake it up totally.
Before going on refactoring, some words how the ActiveRecord finder statement works.
The entire finder statement consists of four query methods (ActiveRecord::QueryMethods) 'where', 'order', 'limit' and 'offset', each returning a chain object, but which are only chained together to a SQL statement and fired finally. And that is important to know. In fact the query methods are only fragments and are combineable, but do not touch the database until the last method was called.
The refactoring starts with extending the model with some scopes:
class Person < ActiveRecord::Base
  attr_accessible :name, :birthday
  default_scope order(:name)
  scope :kids, where(:birthday => 18.year.ago..Time.current)

  def self.range per_page=10, page=nil
    return scoped if page.nil? or page <= 0
    limit(per_page).offset(page * per_page)
  end
end
The default scope always orders by the people name.
Using the first named scope 'kids' appends the condition searching for alle people beeing younger than 18 years old. A scope is just a static class method.
And that is why the second named scope 'range' is one. The default value for the 'per_page' parameter is 10 and 'page' can even be nil. If that is the case, a blank scope object is returned, preventing the chain to be polluted.
Right, the model is fatter, because there was logic added, but the controller is skinnier:
class PeopleController < ApplicationController
  def index
    @people = Person.kids.range(10, params[:page])
  end
end
You should scope you ActiveRecord queries, because:
The chains and their logic are reuseable
The chains can be combined
The query finders themselves are more readable
The logic is encapsulated and therefore better maintainable (think of having people queries spread all over your application and you have to change the default order)
Further articles of interest:

Supported by Ruby 2.1.1 and Ruby on Rails 3.2.17