IS_REVIEWABLE ~ concept version ~
Rails: Make an ActiveRecord resource ratable/reviewable (rating + comment), without the usual extra code-smell.
Why another rating-plugin?
Many reasons; I felt the existing plugins all suck in one way or another (sorry, no hating). This is why IsReviweable is better if you ask me:
- Don’t do assumptions that your rater/reviewer model is
User
. Relying on polymorphic assocation completely, so your reviewer can be…DonaldDuck
. - …and optionally even accepts an IP as rater/reviewer. Disabled by default though.
- Don’t make assumptions about your Review model – you can extend it without meta-programming, good ’ol subclassing folks.
- Don’t make assumptions about what rating scale you wanna have, how the rating scale should be divided, or average rating rounding precision. The 1-5 scale is 80% of the cases, but there’s no real reason or overhead to support any scale. To sum it up: Scale can consist negative and/or positive range or explicit integer/float values…and you won’t even notice the difference on the outside. See the examples! =)
- Possible to submit additional custom attributes while rating, such as
title
andbody
to make up a “review” instead of just a “rating”. Feel free. - Very sassy finders implemented using named scopes, i.e. less code smell.
- No lame out-of-the-box views/javascripts/images that you probably won’t use in the final product anyway. That should be an extension to the plugin.
- Transparently supports column-caching expensive calculations for the reviewable model. Will simply be turned on if these fields exists – otherwise fallback with an optimized DB hit instead.
- If I can say it myself…this code should be very solid, optimized, and DRY. Shoot the messenger! o<;)
If you got suggestions how it could be even better, just hit me up. I appreciate critics for the cause of a rock solid plugin for rating/reviewing. Such a common “problem” as rating/reviewing should have had a great solution by now!
Installation
Gem:
sudo gem install is_reviewable
and in config/environment.rb
:
config.gem 'is_reviewable'
Plugin:
./script/plugin install git://github.com/grimen/is_reviewable.git
Usage
1. Generate migration:
$ ./script/generate is_reviewable_migration
Generates db/migrations/{timestamp}_is_reviewable_migration
with:
class IsReviewableMigration < ActiveRecord::Migration def self.up create_table :reviews do |t| t.references :reviewable, :polymorphic => true t.references :reviewer, :polymorphic => true t.string :ip, :limit => 24 t.float :rating t.text :body # # Custom fields goes here... # # t.string :title # t.string :mood # ... # t.timestamps end add_index :reviews, :reviewer_id add_index :reviews, :reviewer_type add_index :reviews, [:reviewer_id, :reviewer_type] add_index :reviews, [:reviewable_id, :reviewable_type] end def self.down drop_table :reviews end end
2. Make your model reviewable:
class Post < ActiveRecord::Base is_reviewable :scale => 0..5 end
or, with explicit reviewer (or reviewers):
class Book < ActiveRecord::Base # Setup associations for the reviewer class(es) automatically, and specify an explicit scale instead. is_reviewable :by => [:users, :ducks], :scale => 0..5 end
3. …and here we go:
Examples:
Review.destroy_all # just in case... @post = Post.first @user = User.first @post.review!(:by => @user, :rating => 2) # new reviewer (type: object) => create @post.review!(:by => @user, :rating => 5) # same reviewer (type: object) => update @post.total_reviews # => 1 @post.average_rating # => 5.0 @post.review!(:by => '128.0.0.0', :rating => 4) # new reviewer (type: IP) => create @post.review!(:by => '128.0.0.0', :rating => 2) # same reviewer (type: IP) => update @post.total_reviews # => 2 @post.average_rating # => 3.5 @post.review!(:by => @user, :rating => nil, :body => "Lorem ipsum...") # same reviewer (type: IP) => update @post.total_reviews # => 2 @post.average_rating # => 2.0, i.e. don't count nil (a.k.a. "no opinion") @post.unreview!(:by => '128.0.0.0') # delete existing review (type: IP) => destroy @post.total_reviews # => 1 @post.average_rating # => 2.0 @post.reviews # => reviews on @post @post.reviewers # => reviewers that reviewed @post @user.reviews # => reviews by @user @user.reviewables # => reviewable objects that got reviewed by @user # TODO: A few more samples... # etc...
Mixin Arguments
The is_reviewable
mixin takes some hash arguments for customization:
Basic
-
:by
– the reviewer model(s), e.g. User, Account, etc. (accepts either symbol or class, i.e.User
<=>:user
<=>:users
, or an array of such if there are more than one reviewer model). The reviewer model will be setup for you. Note: Polymorhic, so it accepts any model. Default:nil
. -
:scale
/:range
/:values
– range, or array, of valid rating values. Default:1..5
. Note: Negative values are allowed too, and a range of values are not required, i.e. [-1, 1] is valid as well as [1,3,5]. =) -
:accept_ip
– accept anonymous users uniquely identified by IP (well…you handle the bots =D). See examples below how to use this as your visitor object. Default:false
.
Advanced
-
:total_precision
– maximum number of digits for the average rating value. Default:1
. -
:step
– useful if you want to specify a custom step for each scale value within a range of values. Default:1
for range of fixnum, auto-detected based on first value in range of float. -
:steps
– similar to:step
(they relate to each other), but instead of specifying a step you can specify how many steps you want. Default: auto-detected based on custom or default value:step
.
Aliases
To make the usage of IsReviewable a bit more generic (similar to other plugins you may use), there are two useful aliases for this purpose:
-
Review#owner
<=>Review#reviewer
-
Review#object
<=>Review#reviewable
Example:
@post.reviews.first.owner == post.reviews.first.reviewer # => true @post.reviews.first.object == post.reviews.first.reviewable # => true
Finders (Named Scopes)
IsReviewable has plenty of useful finders implemented using named scopes. Here they are:
Review
Order:
-
in_order
– most recent reviews last (order by creation date). -
most_recent
– most recent reviews first (opposite ofin_order
above). -
lowest_rating
– reviews with lowest ratings first. -
highest_rating
– reviews with highest ratings first.
Filter:
-
limit(<number_of_items>)
– maximum<number_of_items>
reviews. -
since(<created_at_datetime>)
– reviews created since<created_at_datetime>
. -
recent(<datetime_or_size>)
– if DateTime: reviews created since<datetime_or_size>
, else if Fixnum: pick last<datetime_or_size>
number of reviews. -
between_dates(<from_date>, to_date)
– reviews created between two datetimes. -
with_rating(<rating_value_or_range>)
– reviews with(in) rating value (or range)<rating_value_or_range>
. -
with_a_rating
– reviews with a rating value, i.e. not nil. -
without_a_rating
– opposite ofwith_a_rating
(above). -
with_a_body
– reviews with a body/comment, i.e. not nil/blank. -
without_a_body
– opposite ofwith_a_body
(above). -
complete
– reviews with both rating and comments, i.e. “full reviews” where. -
of_reviewable_type(<reviewable_type>)
– reviews of<reviewable_type>
type of reviewable models. -
by_reviewer_type(<reviewer_type>)
– reviews of<reviewer_type>
type of reviewer models. -
on(<reviewable_object>)
– reviews on the reviewable object<reviewable_object>
. -
by(<reviewer_object>)
– reviews by the<reviewer_object>
type of reviewer models.
Reviewable
TODO: Documentation on named scopes for Reviewable.
Reviewer
TODO: Documentation on named scopes for Reviewer.
Examples using finders:
@user = User.first @post = Post.first @post.reviews.recent(10) # => [10 most recent reviews] @post.reviews.recent(1.week.ago) # => [reviews created since 1 week ago] @post.reviews.with_rating(3.5..4.0) # => [all reviews on @post with rating between 3.5 and 4.0] @post.reviews.by_reviewer_type(:user) # => [all reviews on @post by User-objects] # ...or: @post.reviews.by_reviewer_type(:users) # => [all reviews on @post by User-objects] # ...or: @post.reviews.by_reviewer_type(User) # => [all reviews on @post by User-objects] @user.reviews.on(@post) # => [all reviews by @user on @post] @post.reviews.by(@user) # => [all reviews by @user on @post] (equivalent with above) Review.on(@post) # => [all reviews on @user] <=> @post.reviews Review.by(@user) # => [all reviews by @user] <=> @user.reviews # etc, etc. It's all named scopes, so it's really no new hokus-pokus you have to learn.
Additional Methods
Note: See documentation (RDoc).
Extend the Review model
This is optional, but if you wanna be in control of your models (in this case Review
) you can take control like this:
class Review < IsReviewable::Review # Do what you do best here... (stating the obvious: core IsReviewable associations, named scopes, etc. will be inherited) end
Caching
If the visitable class table – in the sample above Post
– contains a columns cached_total_reviews
and cached_average_rating
, then a cached value will be maintained within it for the number of reviews and the average rating the object have got.
Additional caching fields (to a reviewable model table):
class AddIsReviewableToPostsMigration < ActiveRecord::Migration def self.up # Enable is_reviewable-caching. add_column :posts, :cached_total_reviews, :integer add_column :posts, :cached_average_rating, :integer end def self.down remove_column :posts, :cached_total_reviews remove_column :posts, :cached_average_rating end end
Example
Controller
Depending on your implementation: You might – or might not – need a Controller, but for most cases where you only want to allow rating of something, a controller most probably is overkill. In the case of a review, this is how one cold look like (in this example, I’m using the excellent the InheritedResources):
Example: app/controllers/reviews_controller.rb
:
class ReviewsController < InheritedResources::Base actions :create, :update, :destroy respond_to :js layout false end
..or in the more basic rating case – app/controllers/posts_controller.rb
:
class PostsController < InheritedResources::Base actions :all respond_to :html, :js layout false if request.format == :js def rate begin @post.review! :by => current_user, params.slice(:rating, :body) rescue flash[:error] = 'Sad panda...could not rate for some reason. O_o' end respond_to do |format| format.html { redirect_to @post } format.js # app/views/posts/rate.js.rjs end end end
Routes
config/routes.rb
map.resources :posts, :member => {:rate => :put}
Views
IsReviewable comes with no view templates (etc.) because of already stated reasons, but basic rating mechanism is trivial to implement (in this example, I’m using HAML because I despise ERB):
Example: app/views/posts/show.html.haml
%h1 = @post.title %p = @post.body %p = "Your rating:" #rating_wrapper= render '/reviews/rating', :resource => @post
Example: app/views/reviews/_rating.html.haml
.rating - if resource.present? && resource.reviewable? - if reviewer.present? - current_rating = resource.review_by(reviewer).try(:rating) - resource.reviewable_scale.each do |rating| = link_to_remote "#{rating.to_i}", :url => rate_post_path(resource, :rating => rating.to_i), :method => :put, :html => {:class => "rate rated_#{rating.to_i}#{' current' if current_rating == rating}"} = link_to_remote "no opinion", :url => rate_post_path(resource, :rating => nil), :method => :put, :html => {:class => "rate rated_none#{' current' unless current_rating}"} - else # static rating - current_rating = resource.average_rating.round - resource.reviewable_scale.each do |rating| {:class => "rate rated_#{rating}#{' current' if current_rating == rating}"}
Note: Skipping review-body (etc.) in this view sample, but that is straightforward to implement anyways.
JavaScript/AJAX
…and finally – as we in this example using AJAX – the javascript response (in this example, I’m using RJS + jQuery – don’t get me started on why I don’t use Prototype for UI…).
Example: app/views/reviews/rate.js.rjs
page.replace_html '#rating_wrapper', render('/reviews/rating', :reviewable => @post, :reviewer => current_user) page.visual_effect :highlight, '.rating .current'
Done! =)
Design Implications: Additional Use Cases
Like/Dislike
IsReviewable is designed in such way that you as a developer are not locked to how traditional rating works. As an example, this is how you could implement like/dislike (like VoteFu) pattern using IsReviewable:
Example:
class Post < ActiveRecord::Base is_reviewable :by => :users, :values => [-1, 1] end
Note: :values
is an alias for :scale
for semantical reasons in cases like these.
Dependencies
For testing: shoulda, redgreen, acts_as_fu, and sqlite3-ruby.
Notes
- Tested with Ruby 1.8.6 – 1.9.1 and Rails 2.3.2 – 2.3.4.
- Let me know if you find any bugs; not used in production yet so consider this a concept version.
TODO
Priority:
- bug: Accept multiple reviewers (of different types):
average_rating_by
, etc.. - documentation: A few more README-examples.
- feature: Reviewable on multiple contexts, e.g.
is_reviewable :on => [:acting, :writing, ...]
. Alias methodis_reviewable_on
. - feature: Useful finders for
Reviewable
. - feature: Useful finders for
Reviewer
. - testing: More thorough tests, especially for named scopes which is a bit tricky.
- refactor: Refactor generic stuff to new gem,
is_base
, and add as gem dependency. Reason: Share the same patterns for my very similar ActiveRecord plugins: is_reviewable, is_visitable, is_commentable, and future additions.
Maybe:
- feature: Make alias helpers to implement functionality like VoteFu (http://github.com/peteonrails/vote_fu), simply just aliasing methods with existing ones with hardcoded parameters. Not required because this is supported already, it’s all about semantics and sassy code.
Related Links
…that might be of interest.
- jQuery Star Rating – javascript star rating plugin for Rails on jQuery, if you don’t want to do the rating widget on your own. It should be quite straightforward to customize the appearance of it for your needs too.
License
Released under the MIT license.
Copyright © Jonas Grimfelt