Exploring Paginations
Today I want to explore how to use Rail’s pagination support
when you use find_by_sql, e.g. for complex cases that
include joins, custom conditions and piggy-back attributes.
The main difficulty is how to get the correct limit-clause into your SQL.
Since version 0.11 Rails has built-in support for pagination, i.e. the technique
of splitting a large number of records over several pages. For simple cases this is really simple. If you
want to show all records from the persons table with 20 entries per page, for example it gets as easy
as adding "paginate :persons, :per_page => 20" to your controller.
However, I had some trouble getting this to work with a more complicated query (that involves joins and piggy-back attributes) and I thought it might be helpful to some people if I share this knowledge and ponder about some improvements.
So, let’s consider an e-mail-account management system. We have three classes EmailAddress,
Domain and Account where e-mail-addresses belong to a domain and an account.
class EmailAddress < ActiveRecord::Base
belongs_to :domain
belongs_to :account
end
class Domain < ActiveRecord::Base
end
class Account < ActiveRecord::Base
end
As further attributes, e-mail-addresses have a local part and domains
have a name.
Now suppose we want to see all e-mail-addresses belonging to an account (could be hundreds) and want to see about 100 addresses per page. We want e-mail-addresses to be presented as local@domain, but retrieving the domain object for every of our (100) e-mail-address objects can be pretty slow, so we want to use the piggy-back approach.
We don’t expect the pagination macro to work in such a complicated case, so
our controller will look something like this:
def list
account = Account.find(@params["id"])
@address_pages = Paginator.new EmailAddress.count_for_account(account),
100, @params['page']
@addresses = EmailAdress.find_for_account account
end
The count_for_account method is trivial, but this does not work, since the find_for_account
should only return the addresses for the current page, so we have to pass this in as well:
@addresses = EmailAdress.find_for_account acount, @address_pages.current
Now the tricky part is how to construct the SQL, so I’ll show this method:
class EmailAddress < ActiveRecord::Base
def self.find_for_account(account,page=nil)
sql = "SELECT addresses.*, domains.name AS domain_name "
sql << "FROM addresses, domains "
sql << "WHERE addresses.domain_id=domains.id "
sql << "AND addresses.account_id=? "
sql << "ORDER BY domains.name, addresses.local "
connection.add_limit!(sql,page.to_sql) if page
find_by_sql( [sql, account.id ] )
end
end
(For those unfamiliar with the piggy-back approach: This will return an array of e-mail-adress objects that have
an additional attribute domain_name.)
Notice the connection.add_limit line. First of all, it’s great that this is possible in rails! It shows the flexible design
of AR in general and the Paginator although I had to read the source to find out how to do it. But once you know how to do it, it’s
not so hard after all and that’s why I wrote this post.
So I wondered if there was an easier solution. ActiveRecord’s find_all already has an (undocumented) joins
joins attribute, so let’s take a look at the source to see what it does:
def find_all(conditions = nil, orderings = nil, limit = nil, joins = nil)
sql = "SELECT * FROM #{table_name} "
sql << "#{joins} " if joins
add_conditions!(sql, conditions)
sql << "ORDER BY #{orderings} " unless orderings.nil?
limit = sanitize_sql(limit) if limit.is_a? Array and limit.first.is_a? String
connection.add_limit!(sql, limit) if limit
find_by_sql(sql)
end
Great, that’s almost what we want, but the "SELECT * FROM" bothers me, because it means you’ll get into trouble
when you are joining tables that have columns with the same name. I believe it would be better if AR was generating SQL that only
selects field from the model’s table, plus another optional parameter fields that allows you to add piggy-back fields.
If these parameters were also present in Paginator’s paginate method we could even write this in our controller as simple as
this without any support in the model.
address_pages, addresses = paginate(
:email_addresses,
:condition =>
"addresses.domain_id=domains.id AND addresses.account_id=?",
:order_by => "domains.name, addresses.local",
:join => ", domains",
:fields => ["domains.name AS domain_name"],
:params => [ account.id ]
)
Okay, still pretty long, so is it worth it?