is_reviewable icon indicating copy to clipboard operation
is_reviewable copied to clipboard

Rails: Make an ActiveRecord model ratable/reviewable (rating + comment), without the usual extra code-smell.

h1. IS_REVIEWABLE ~ concept version ~

Rails: Make an ActiveRecord resource ratable/reviewable (rating + comment), without the usual extra code-smell.

h2. 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@ and @body@ 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!

h2. 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

h2. Usage

h3. 1. Generate migration:

$ ./script/generate is_reviewable_migration

Generates @db/migrations/{timestamp}_is_reviewable_migration@ with:

class IsReviewableMigration  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

h3. 2. Make your model reviewable:

class Post  0..5
end

or, with explicit reviewer (or reviewers):

class Book  [:users, :ducks], :scale => 0..5
end

h3. 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...

h2. 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@.

h2. 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

h2. Finders (Named Scopes)

IsReviewable has plenty of useful finders implemented using named scopes. Here they are:

h3. @Review@

Order:

  • @in_order@ - most recent reviews last (order by creation date).
  • @most_recent@ - most recent reviews first (opposite of @in_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 of @with_a_rating@ (above).
  • @with_a_body@ - reviews with a body/comment, i.e. not nil/blank.
  • @without_a_body@ - opposite of @with_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.

h3. @Reviewable@

TODO: Documentation on named scopes for Reviewable.

h3. @Reviewer@

TODO: Documentation on named scopes for Reviewer.

h3. 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.

h2. Additional Methods

Note: See documentation (RDoc).

h2. 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 

h2. 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 

h2. Example

h3. 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":http://github.com/josevalim/inherited_resources):

Example: @app/controllers/reviews_controller.rb@:

class ReviewsController 

..or in the more basic rating case - @app/controllers/posts_controller.rb@:

class PostsController  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

h3. Routes

@config/routes.rb@

map.resources :posts, :member => {:rate => :put}

h3. 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.

h3. 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! =)

h2. Design Implications: Additional Use Cases

h3. 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  :users, :values => [-1, 1]
end

Note: @:values@ is an alias for @:scale@ for semantical reasons in cases like these.

h2. Dependencies

For testing: "shoulda":http://github.com/thoughtbot/shoulda, "redgreen":http://gemcutter.org/gems/redgreen, "acts_as_fu":http://github.com/nakajima/acts_as_fu, and "sqlite3-ruby":http://gemcutter.org/gems/sqlite3-ruby.

h2. 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.

h2. TODO

h3. 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 method @is_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.

h3. 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.

h2. Related Links

...that might be of interest.

  • "jQuery Star Rating":http://github.com/rathgar/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.

h2. License

Released under the MIT license. Copyright (c) "Jonas Grimfelt":http://github.com/grimen

!https://d2weczhvl823v0.cloudfront.net/grimen/is_reviewable/trend.png(Bitdeli Badge)!:https://bitdeli.com/free