Skip to content

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

License

Notifications You must be signed in to change notification settings

elpic/is_reviewable

 
 

Repository files navigation

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 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!

Installation

Gem:

Add in your Gemfile@:

gem 'is_reviewable'

Usage

1. Generate migration:

$ rails g is_reviewable:install

or

$ rails generate is_reviewable:install

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

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

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

About

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

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Ruby 100.0%