views:

483

answers:

4

Let's say I have Book model and an Author model. I want to list all authors sorted by their book count. What's the best way to do that?

I know how to do this in SQL, either by doing where .. in with a nested select or with some join. But what I'd like to know is how to do this nicely with ActiveRecord.

+5  A: 

I suggest counter caching the book count as another attribute on Author (Rails supports this with an option on the association). This is by far the fastest method, and Rails is pretty good about making sure it keeps the count in sync.

_Kevin
Thanks for the quick and accurate answer. I used your answer to search for more information and found http://railscasts.com/episodes/23-counter-cache-column which is very useful too. I've selected railsninja's answer though because it's more comprehensive and would help passer-bys better.
sjmulder
+4  A: 

This is from http://www.ruby-forum.com/topic/164155

Book.find(:all, :select => "author_id, count(id) as book_count", :group => "author_id", :order => "book_count")

dplante
Right. This is basically just doing a SQL query and getting the DBMS to do the work, and that's the simplest and best way to do it. In particular, you want to do this to avoid doing many queries (possibly as many as one per author) when one query will do what you need.
Curt Sampson
+4  A: 

As Kevin has suggested, counter_cache is the easiest option, definitely what I would use.

class Author < ActiveRecord::Base
  has_many :books, :counter_cache => true
end

class Book < ActiveRecord::Base
  belongs_to :author
end

And if you are using Rails 2.3 and you would like this to be the default ordering you could use the new default_scope method:

class Author < ActiveRecord::Base
  has_many :books, :counter_cache => true

  default_scope :order => "books_count DESC"
end

books_count is the field that performs the counter caching behaviour, and there is probably a better way than using it directly in the default scope, but it gives you the idea and will get the job done.

EDIT:

In response to the comment asking if counter_cache will work if a non rails app alters the data, well it can, but not in the default way as Rails increments and decrements the counter at save time. What you could do is write your own implementation in an after_save callback.

class Author < ActiveRecord::Base
  has_many :books

  after_save :update_counter_cache

  private
    def update_counter_cache
      update_attribute(:books_count, self.books.length) unless self.books.length == self.books_count
    end
end

Now you don't have a counter_cache installed, but if you name the field in the database books_count as per the counter_cache convention then when you look up:

@Author = Author.find(1)
puts @author.books.size

It will still use the counter cached number instead of performing a database lookup. Of course this will only work when the rails app updates the table, so if another app does something then your numbers may be out of sync until the rails application comes back an has to save. The only way around this that I can think of is a cron job to sync numbers if your rails app doesn't do lookup up things often enough to make it not matter.

railsninja
does counter caching work if a non-rails application updates the data in the database directly (i.e. removes a book)?
dplante
I suppose not, at least until you save the record again from te Rails app.
sjmulder
A: 

This is a correction for railsninja's answer.

:counter_cache => true belongs after belongs_to in class Book.

class Author < ActiveRecord::Base
  has_many :books
end

class Book < ActiveRecord::Base
  belongs_to :author, :counter_cache => true
end