Rails: Ratings


I had to add a ratings widget to my app, sort of like Amazon has. I decided to use the jQuery Star Rating Plugin on the front end. That worked out well. I decided to code the back end from scratch. That took longer than I would have expected, but the code is super tight.

Only logged in users can vote. If a user votes again, it should update his existing vote rather than letting him stuff the ballot box. Aside from keeping track of the ratings for each user, I wanted the item itself, i.e. the book, to have a rating_average field. Furthermore, I didn't want rating_average to have to calculate the average rating every time I loaded the page. It should be cached in the same way that Rails can cache the number of children a parent has.

Here's what my schema looks like:
class CreateBookRatings < ActiveRecord::Migration
def self.up
execute %{
CREATE TABLE book_ratings (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
book_id INT NOT NULL,
rating FLOAT NOT NULL,
created_at DATETIME,
updated_at DATETIME,

FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE,

UNIQUE INDEX (book_id, user_id),
INDEX (user_id)
) ENGINE = INNODB
}

add_column :books, :rating_average, :float
add_column :books, :rating_count, :int, :null => false, :default => 0
end

def self.down
remove_column :books, :rating_count
remove_column :books, :rating_average

drop_table :book_ratings
end
end
I'll skip the controller, routing, and view. They're relatively straightforward once you understand how the jQuery plugin works. The hardest part was the model:
class BookRating < ActiveRecord::Base
MAX_STARS = 5
SPLIT = 2 # You can have half of a star.

belongs_to :user
belongs_to :book
validates_numericality_of :rating, :greater_than_or_equal_to => 0,
:less_than_or_equal_to => BookRating::MAX_STARS
attr_accessible :rating

# Save a book rating.
#
# This may raise ActiveRecord::RecordInvalid if the rating is invalid.
#
# This automatically calls recalculate_average_and_count!.
def self.rate_book!(user, book, rating)
if rating.nil?
BookRating.delete_all(["user_id = ? AND book_id = ?", user.id, book.id])
else
book_rating = BookRating.new(:rating => rating)
book_rating.user = user
book_rating.book = book

# Validate manually so that I can use custom SQL.
if book_rating.invalid?
raise ActiveRecord::RecordInvalid.new(book_rating)
end

# This lets users create a new rating or update their existing rating.
# Unfortunately, insert_sql can't take an array, so I have to use
# connection.quote manually. I'm using book_rating.rating so that
# ActiveRecord can take care of the casting.
connection.insert_sql(%{
INSERT INTO book_ratings (user_id, book_id, rating, created_at, updated_at)
VALUES (#{connection.quote(user.id)},
#{connection.quote(book.id)},
#{connection.quote(book_rating.rating)},
NOW(), NOW())
ON DUPLICATE KEY UPDATE rating = #{connection.quote(book_rating.rating)},
updated_at = NOW()
})
end

recalculate_average_and_count!(book)
end

# Update book.rating_average and book.rating_count.
#
# I can calculate the average without having to scan the table when the
# user creates a new book rating, but that falls apart if he updates his
# existing rating. Hence, in the name of simplicitly, I'll just let the
# database calculate the average.
#
# I'm not going to put rate_book! and recalculate_average_and_count! into a
# single transaction. Transactions break my tests when I
# use_transactional_features, and in this case, it just isn't that crucial.
def self.recalculate_average_and_count!(book)
options = {:conditions => ["book_id = ?", book.id]}
book.rating_average = BookRating.average(:rating, options)
book.rating_count = BookRating.count(:rating, options)
book.save!
end
end
The most interesting bits are the use of "ON DUPLICATE KEY UPDATE" and the fact that the rating average is updated every time the user rates a book.

I haven't yet added Ajax to the mix. The user actually has to click a button to submit the form. However, the widget still works if JavaScript is disabled. The guys who wrote the jQuery plugin did a good job making use of semantic HTML. Because it works even without JavaScript, I was able to write Cucumber and RSpec tests for everything :)

Comments

I looked at acts_as_ratable, http://rubyforge.org/projects/ratable/, but:

* It's really old.
* It's very stale--there just isn't anything going on.
* There's no documentation.
* There aren't any tests.
* It doesn't cache the average rating--rather it calculates it from scratch in Ruby everytime it's requested.
* My code for preventing duplicate votes is more solid.

It does have one advantage over my code. It's written using a polymorphic association, which makes sense if you want to add ratings to several different models in your application. I didn't bother because I don't need that. If I did want ratings for Foos and Bars, I'd probably create foo_ratings and bar_ratings tables. Those tables could even be placed into separate databases, as needed, to improve scalability.
Nick V. said…
This is good stuff. Thanks!
I had also looked looked at the acts_as_rated and came to the same conclusion... stale. Acts_as_rated mentions that it was last tested with Rails 1.2 and updated March of 08.
Thanks for the kudos :)
Diego A. said…
Great use of the star rating plugin, great article...
Thanks, Diego.
Anonymous said…
I want to shearing about this http://minimalbugs.com/questions/dynamic-attr_accessible-based-on-user-permissions