Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial import into Rubyforge

git-svn-id: svn://rubyforge.org/var/svn/acts-as-rated/trunk/acts_as_rated@1 d2fe85f9-699c-4bcc-83cd-509c172d722e
  • Loading branch information...
commit da069cb618b3af7cbada6af9fe2d7f73be890d8e 0 parents
guynaor authored
20 MIT-LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2007 Guy Naor (Famundo LLC)
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
70 README
@@ -0,0 +1,70 @@
+= acts_as_rated
+
+The ultimate rating system for ActiveRecord models. Highly flexible and configurable, while easy to use with the defaults. Supports 3 different ways to manage the statistics, and creates all the needed associations for easy access to everything.
+
+Comes complete with the needed migrations code to make it easy to add to any project.
+
+<em>NOTE:</em> It uses some advanced SQL constructs that might not be supported by all servers. It was tested on Postgres only. If you have patches/fixes for other databases, please send them and I will add them to the plugin.
+
+== Features
+
+* Rate any model
+* Optionally add fields to the rated objects to optimize speed
+* Optionally add an external rating statistics table with a record for each rated model
+* Can work with the added fields, external table or just using direct SQL count/avg calls
+* Use any model as the rater (defaults to User)
+* Limit the range of the ratings
+* Average, total and number of ratings
+* Find objects by ratings or rating ranges
+* Find objects by rater
+* Extensively tested
+
+== Basic Details
+
+Install
+
+* script/plugin install svn://rubyforge.org/var/svn/acts-as-rated/trunk/acts_as_rated
+* gem install - <b>comming soon</b>
+
+Rubyforge project
+
+* http://rubyforge.org/projects/acts-as-rated
+
+RDocs
+
+* http://acts-as-rated.rubyforge.org
+
+Subversion
+
+* svn://rubyforge.org/var/svn/acts-as-rated
+
+My blog with some comments about the plugin
+
+* http://devblog.famundo.com
+
+Work done as part of Famundo development
+
+* http://www.famundo.com
+
+Contact me at
+
+* guy.naor@famundo.com
+
+== TODO
+* Test with more databases
+* Test with other versions of Rails (tested against 1.2.1)
+* Add view helpers for easy display and entering of the ratings
+
+
+== Testing the plugin
+
+The plugin comes with a full set of tests, both for migrations and for the code itself. The framework was taken from the acts_as_versioned plugin, allowing it to run stand-alone in the test directory.
+
+run the tests:
+ rake test
+
+In order for testing to work, you need to create a database (default name is acts_as_rated_plugin_test) and edit test/database.yml to make sure the login and password are correct. You can also change there the name of the database.
+
+Testing defaults to postgresql, to change it set the environment variable DB to the driver you want to use:
+ env DB='mysql' rake test
+
190 Rakefile
@@ -0,0 +1,190 @@
+require 'rubygems'
+
+Gem::manage_gems
+
+require 'rake/rdoctask'
+require 'rake/packagetask'
+require 'rake/gempackagetask'
+require 'rake/testtask'
+require 'rake/contrib/rubyforgepublisher'
+
+PKG_NAME = 'acts_as_rated'
+PKG_VERSION = '0.2.0'
+PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}"
+PROD_HOST = "guy.naor@famundo.com"
+RUBY_FORGE_PROJECT = 'acts-as-rated'
+RUBY_FORGE_USER = 'guynaor'
+
+desc 'Default: run all tests.'
+task :default => :test
+
+task :test => [:test_plugin, :test_migrations ]
+
+desc 'Test the acts_as_rated plugin.'
+Rake::TestTask.new(:test_plugin) do |t|
+ t.libs << 'lib'
+ t.pattern = 'test/rated_test.rb'
+ t.verbose = true
+end
+
+desc 'Test the acts_as_rated plugin.'
+Rake::TestTask.new(:test_migrations) do |t|
+ t.libs << 'lib'
+ t.pattern = 'test/migration_test.rb'
+ t.verbose = true
+end
+
+desc 'Generate documentation for the acts_as_rated plugin.'
+Rake::RDocTask.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = 'doc'
+ rdoc.title = "#{PKG_NAME} -- Rating system for ActiveRecord models"
+ rdoc.options << '--line-numbers'
+ rdoc.options << '--inline-source'
+ rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('lib/**/*.rb')
+end
+
+spec = Gem::Specification.new do |s|
+ s.name = PKG_NAME
+ s.version = PKG_VERSION
+ s.platform = Gem::Platform::RUBY
+ s.summary = "Rating system for active record models"
+ s.files = FileList["{lib,test}/**/*"].to_a + %w(README MIT-LICENSE)
+ s.files.delete "test/debug.log"
+ s.require_path = 'lib'
+ s.autorequire = 'acts_as_versioned'
+ s.has_rdoc = true
+ s.test_files = Dir['test/**/*_test.rb']
+ s.add_dependency 'activerecord', '>= 1.10.1'
+ s.add_dependency 'activesupport', '>= 1.1.1'
+ s.author = "Guy Naor"
+ s.email = "guy.naor@famundo.com"
+ s.homepage = "http://devblog.famundo.com"
+end
+
+Rake::GemPackageTask.new(spec) do |pkg|
+ pkg.need_tar = true
+end
+
+desc "Publish the API documentation"
+task :pdoc => [:rdoc] do
+ Rake::RubyForgePublisher.new(RUBY_FORGE_PROJECT, RUBY_FORGE_USER).upload
+end
+
+desc 'Publish the gem and API docs'
+task :publish => [:pdoc, :rubyforge_upload]
+
+desc "Publish the release files to RubyForge."
+task :rubyforge_upload => :package do
+ files = %w(gem tgz).map { |ext| "pkg/#{PKG_FILE_NAME}.#{ext}" }
+
+ if RUBY_FORGE_PROJECT then
+ require 'net/http'
+ require 'open-uri'
+
+ project_uri = "http://rubyforge.org/projects/#{RUBY_FORGE_PROJECT}/"
+ project_data = open(project_uri) { |data| data.read }
+ group_id = project_data[/[?&]group_id=(\d+)/, 1]
+ raise "Couldn't get group id" unless group_id
+
+ # This echos password to shell which is a bit sucky
+ if ENV["RUBY_FORGE_PASSWORD"]
+ password = ENV["RUBY_FORGE_PASSWORD"]
+ else
+ print "#{RUBY_FORGE_USER}@rubyforge.org's password: "
+ password = STDIN.gets.chomp
+ end
+
+ login_response = Net::HTTP.start("rubyforge.org", 80) do |http|
+ data = [
+ "login=1",
+ "form_loginname=#{RUBY_FORGE_USER}",
+ "form_pw=#{password}"
+ ].join("&")
+ http.post("/account/login.php", data)
+ end
+
+ cookie = login_response["set-cookie"]
+ raise "Login failed" unless cookie
+ headers = { "Cookie" => cookie }
+
+ release_uri = "http://rubyforge.org/frs/admin/?group_id=#{group_id}"
+ release_data = open(release_uri, headers) { |data| data.read }
+ package_id = release_data[/[?&]package_id=(\d+)/, 1]
+ raise "Couldn't get package id" unless package_id
+
+ first_file = true
+ release_id = ""
+
+ files.each do |filename|
+ basename = File.basename(filename)
+ file_ext = File.extname(filename)
+ file_data = File.open(filename, "rb") { |file| file.read }
+
+ puts "Releasing #{basename}..."
+
+ release_response = Net::HTTP.start("rubyforge.org", 80) do |http|
+ release_date = Time.now.strftime("%Y-%m-%d %H:%M")
+ type_map = {
+ ".zip" => "3000",
+ ".tgz" => "3110",
+ ".gz" => "3110",
+ ".gem" => "1400"
+ }; type_map.default = "9999"
+ type = type_map[file_ext]
+ boundary = "rubyqMY6QN9bp6e4kS21H4y0zxcvoor"
+
+ query_hash = if first_file then
+ {
+ "group_id" => group_id,
+ "package_id" => package_id,
+ "release_name" => PKG_FILE_NAME,
+ "release_date" => release_date,
+ "type_id" => type,
+ "processor_id" => "8000", # Any
+ "release_notes" => "",
+ "release_changes" => "",
+ "preformatted" => "1",
+ "submit" => "1"
+ }
+ else
+ {
+ "group_id" => group_id,
+ "release_id" => release_id,
+ "package_id" => package_id,
+ "step2" => "1",
+ "type_id" => type,
+ "processor_id" => "8000", # Any
+ "submit" => "Add This File"
+ }
+ end
+
+ query = "?" + query_hash.map do |(name, value)|
+ [name, URI.encode(value)].join("=")
+ end.join("&")
+
+ data = [
+ "--" + boundary,
+ "Content-Disposition: form-data; name=\"userfile\"; filename=\"#{basename}\"",
+ "Content-Type: application/octet-stream",
+ "Content-Transfer-Encoding: binary",
+ "", file_data, ""
+ ].join("\x0D\x0A")
+
+ release_headers = headers.merge(
+ "Content-Type" => "multipart/form-data; boundary=#{boundary}"
+ )
+
+ target = first_file ? "/frs/admin/qrs.php" : "/frs/admin/editrelease.php"
+ http.post(target + query, data, release_headers)
+ end
+
+ if first_file then
+ release_id = release_response.body[/release_id=(\d+)/, 1]
+ raise("Couldn't get release id") unless release_id
+ end
+
+ first_file = false
+ end
+ end
+end
3  init.rb
@@ -0,0 +1,3 @@
+require 'acts_as_rated'
+
+
412 lib/acts_as_rated.rb
@@ -0,0 +1,412 @@
+module ActiveRecord #:nodoc:
+ module Acts #:nodoc:
+
+ # == acts_as_rated
+ # Adds rating capabilities to any ActiveRecord object.
+ # It has the ability to work with objects that have or don't special fields to keep a tally of the
+ # ratings and number of votes for each object.
+ # In addition it will by default use the User model as the rater object and keep the ratings per-user.
+ # It can be configured to use another class, or not use a rater at all, just keeping a global rating
+ #
+ # Special methods are provided to create the ratings table and if needed, to add the special fields needed
+ # to keep per-objects ratings fast for access to rated objects. Can be easily used in migrations.
+ #
+ # == Example of usage:
+ #
+ # class Book < ActiveRecord::Base
+ # acts_as_rated
+ # end
+ #
+ # bill = User.find_by_name 'bill'
+ # jill = User.find_by_name 'jill'
+ # catch22 = Book.find_by_title 'Catch 22'
+ # hobbit = Book.find_by_title 'Hobbit'
+ #
+ # catch22.rate 5, bill
+ # hobbit.rate 3, bill
+ # catch22.rate 1, jill
+ # hobbit.rate 5, jill
+ #
+ # hobbit.rating_average # => 4
+ # hobbit.rated_total # => 8
+ # hobbit.rated_count # => 2
+ #
+ # hobbit.unrate bill
+ # hobbit.rating_average # => 5
+ # hobbit.rated_total # => 5
+ # hobbit.rated_count # => 1
+ #
+ # bks = Book.find_by_rating 5 # => [hobbit]
+ # bks = Book.find_by_rating 1..5 # => [catch22, hobbit]
+ #
+ # usr = Book.find_rated_by jill # => [catch22, hobbit]
+ #
+ module Rated
+
+ class RateError < RuntimeError; end
+
+ def self.included(base) #:nodoc:
+ base.extend(ClassMethods)
+ end
+
+ module ClassMethods
+
+ # Make the model ratable. Can work both with and without a rater entity (defaults to User).
+ # The Rating model, holding the details of the ratings, will be created dynamically if it doesn't exist.
+ #
+ # * Adds a <tt>has_many :ratings</tt> association to the model for easy retrieval of the detailed ratings.
+ # * Adds a <tt>has_many :raters</tt> association to the onject, unless <tt>:no_rater</tt> is given as a configuration parameter.
+ # * Adds a <tt>has_many :ratings</tt> associations to the rater class.
+ # * Adds a <tt>has_one :rating_statistic</tt> association to the model, if <tt>:with_stats_table => true</tt> is given as a configuration param.
+ #
+ # === Options
+ # * <tt>:rating_class</tt> -
+ # class of the model used for the ratings. Defaults to Rating. This class will be dynamically created if not already defined.
+ # If the class is predefined, it must have in it the following definitions:
+ # <tt>belongs_to :rated, :polymorphic => true</tt> and if using a rater (which is true in most cases, see below) also
+ # <tt>belongs_to :rater, :class_name => 'User', :foreign_key => :rater_id</tt> replace user with the rater class if needed.
+ # * <tt>:rater_class</tt> -
+ # class of the model that creates the rating.
+ # Defaults to User This class will NOT be created, so it must be defined in the app.
+ # Another option will be to keep a session or IP based ID here to prevent multiple ratings from the same client.
+ # * <tt>:no_rater</tt> -
+ # do not keep track of who created the rating. This will change the behaviour
+ # to one that just collects and averages ratings, but doesn't keep track of who
+ # posted the rating. Useful in a public application that doesn't care about
+ # individual votes
+ # * <tt>:rating_range</tt> -
+ # A range object for the acceptable rating value range. Defaults to not limited
+ # * <tt>:with_stats_table</tt> -
+ # Use a separate statistics table to hold the count/total/average rating of the rated object instead of adding the columns to the object's table.
+ # This means we do not have to change the model table. It still holds a big performance advantage over using SQL to get the statistics
+ # * <tt>:stats_class -
+ # Class of the statics table model. Only needed if <tt>:with_stats_table</tt> is set to true. Default to RatingStat.
+ # This class need to have the following defined: <tt>belongs_to :rated, :polymorphic => true</tt>.
+ # And must make sure that it has the attributes <tt>rating_count</tt>, <tt>rating_total</tt> and <tt>rating_avg</tt> and those
+ # must be initialized to 0 on new instances
+ #
+ def acts_as_rated(options = {})
+ # don't allow multiple calls
+ return if self.included_modules.include?(ActiveRecord::Acts::Rated::RateMethods)
+ send :include, ActiveRecord::Acts::Rated::RateMethods
+
+ # Create the model for ratings if it doesn't yet exist
+ rating_class = options[:rating_class] || 'Rating'
+ rater_class = options[:rater_class] || 'User'
+ stats_class = options[:stats_class] || 'RatingStatistic' if options[:with_stats_table]
+
+ unless Object.const_defined?(rating_class)
+ Object.class_eval <<-EOV
+ class #{rating_class} < ActiveRecord::Base
+ belongs_to :rated, :polymorphic => true
+ #{options[:no_rater] ? '' : "belongs_to :rater, :class_name => #{rater_class}, :foreign_key => :rater_id"}
+ end
+ EOV
+ end
+
+ unless stats_class.nil? || Object.const_defined?(stats_class)
+ Object.class_eval <<-EOV
+ class #{stats_class} < ActiveRecord::Base
+ belongs_to :rated, :polymorphic => true
+ end
+ EOV
+ end
+
+ raise RatedError, ":rating_range must be a range object" unless options[:rating_range].nil? || (Range === options[:rating_range])
+ write_inheritable_attribute( :acts_as_rated_options ,
+ { :rating_range => options[:rating_range],
+ :rating_class => rating_class,
+ :stats_class => stats_class,
+ :rater_class => rater_class } )
+ class_inheritable_reader :acts_as_rated_options
+
+ class_eval do
+ has_many :ratings, :as => :rated, :dependent => :delete_all, :class_name => rating_class.to_s
+ has_many(:raters, :through => :ratings, :class_name => rater_class.to_s) unless options[:no_rater]
+ has_one(:rating_statistic, :class_name => stats_class.to_s, :as => :rated, :dependent => :delete) unless stats_class.nil?
+
+ before_create :init_rating_fields
+ end
+
+ # Add to the User (or whatever the rater is) a has_many ratings if working with a rater
+ return if options[:no_rater]
+ rater_as_class = rater_class.constantize
+ return if rater_as_class.instance_methods.include?('find_in_ratings')
+ rater_as_class.class_eval <<-EOS
+ has_many :ratings, :foreign_key => :rater_id, :class_name => #{rating_class.to_s}
+ EOS
+ end
+ end
+
+ module RateMethods
+
+ def self.included(base) #:nodoc:
+ base.extend ClassMethods
+ end
+
+ # Get the average based on the special fields,
+ # or with a SQL query if the rated objects doesn't have the avg and count fields
+ def rating_average
+ return self.rating_avg if attributes.has_key?('rating_avg')
+ return (rating_statistic.rating_avg || 0) rescue 0 if acts_as_rated_options[:stats_class]
+ avg = ratings.average(:rating)
+ avg = 0 if avg.nan?
+ avg
+ end
+
+ # Is this object rated already?
+ def rated?
+ return (!self.rating_count.nil? && self.rating_count > 0) if attributes.has_key? 'rating_count'
+ if acts_as_rated_options[:stats_class]
+ stats = (rating_statistic.rating_count || 0) rescue 0
+ return stats > 0
+ end
+
+ # last is the one where we don't keep the statistics - go direct to the db
+ !ratings.find(:first).nil?
+ end
+
+ # Get the number of ratings for this object based on the special fields,
+ # or with a SQL query if the rated objects doesn't have the avg and count fields
+ def rated_count
+ return self.rating_count || 0 if attributes.has_key? 'rating_count'
+ return (rating_statistic.rating_count || 0) rescue 0 if acts_as_rated_options[:stats_class]
+ ratings.count
+ end
+
+ # Get the sum of all ratings for this object based on the special fields,
+ # or with a SQL query if the rated objects doesn't have the avg and count fields
+ def rated_total
+ return self.rating_total || 0 if attributes.has_key? 'rating_total'
+ return (rating_statistic.rating_total || 0) rescue 0 if acts_as_rated_options[:stats_class]
+ ratings.sum(:rating)
+ end
+
+ # Rate the object with or without a rater - create new or update as needed
+ #
+ # * <tt>value</tt> - the value to rate by, if a rating range was specified will be checked that it is in range
+ # * <tt>rater</tt> - an object of the rater class. Must be valid and with an id to be used.
+ # If the acts_as_rated was passed :with_rater => false, this parameter is not required
+ def rate value, rater = nil
+ # Sanity checks for the parameters
+ rating_class = acts_as_rated_options[:rating_class].constantize
+ with_rater = rating_class.column_names.include? "rater_id"
+ raise RateError, "rating with no rater cannot accept a rater as a parameter" if !with_rater && !rater.nil?
+ if with_rater && !(acts_as_rated_options[:rater_class].constantize === rater)
+ raise RateError, "the rater object must be the one used when defining acts_as_rated (or a descendent of it). other objects are not acceptable"
+ end
+ raise RateError, "rating with rater must receive a rater as parameter" if with_rater && (rater.nil? || rater.id.nil?)
+ r = with_rater ? ratings.find(:first, :conditions => ['rater_id = ?', rater.id]) : nil
+ raise RateError, "value is out of range!" unless acts_as_rated_options[:rating_range].nil? || acts_as_rated_options[:rating_range] === value
+
+ # Find the place to store the rating statistics if any...
+ # Take care of the case of a separate statistics table
+ unless acts_as_rated_options[:stats_class].nil? || @rating_statistic.class.to_s == acts_as_rated_options[:stats_class]
+ self.rating_statistic = acts_as_rated_options[:stats_class].constantize.new
+ end
+ target = self if attributes.has_key? 'rating_total'
+ target ||= self.rating_statistic if acts_as_rated_options[:stats_class]
+ rating_class.transaction do
+ if r.nil?
+ rate = rating_class.new
+ rate.rater_id = rater.id if with_rater
+ if target
+ target.rating_count = (target.rating_count || 0) + 1
+ target.rating_total = (target.rating_total || 0) + value
+ target.rating_avg = target.rating_total / target.rating_count
+ end
+ ratings << rate
+ else
+ rate = r
+ if target
+ target.rating_total += value - rate.rating # Update the total rating with the new one
+ target.rating_avg = target.rating_total / target.rating_count
+ end
+ end
+
+ # Remove the actual ratings table entry
+ rate.rating = value
+ if !new_record?
+ rate.save
+ target.save if target
+ end
+ end
+ end
+
+ # Unrate the rating of the specified rater object.
+ # * <tt>rater</tt> - an object of the rater class. Must be valid and with an id to be used
+ #
+ # Unrate cannot be called for acts_as_rated with :with_rater => false
+ def unrate rater
+ rating_class = acts_as_rated_options[:rating_class].constantize
+ if !(acts_as_rated_options[:rater_class].constantize === rater)
+ raise RateError, "The rater object must be the one used when defining acts_as_rated (or a descendent of it). other objects are not acceptable"
+ end
+ raise RateError, "Rater must be a valid and existing object" if rater.nil? || rater.id.nil?
+ raise RateError, 'Cannot unrate if not using a rater' if !rating_class.column_names.include? "rater_id"
+ r = ratings.find(:first, :conditions => ['rater_id = ?', rater.id])
+ if !r.nil?
+ target = self if attributes.has_key? 'rating_total'
+ target ||= self.rating_statistic if acts_as_rated_options[:stats_class]
+ if target
+ rating_class.transaction do
+ target.rating_count -= 1
+ target.rating_total -= r.rating
+ target.rating_avg = target.rating_total / target.rating_count
+ target.rating_avg = 0 if target.rating_avg.nan?
+ end
+ end
+
+ # Removing the ratings table entry
+ r.destroy
+ target.save if !target.nil?
+ end
+ end
+
+ private
+
+ def init_rating_fields #:nodoc:
+ if attributes.has_key? 'rating_total'
+ self.rating_count ||= 0
+ self.rating_total ||= 0
+ self.rating_avg ||= 0
+ end
+ end
+
+ end
+
+ module ClassMethods
+
+ # Generate the ratings columns on a table, to be used when creating the table
+ # in a migration. This is the preferred way to do in a migration that creates
+ # new tables as it will make it as part of the table creation, and not generate
+ # ALTER TABLE calls after the fact
+ def generate_ratings_columns table
+ table.column :rating_count, :integer
+ table.column :rating_total, :decimal
+ table.column :rating_avg, :decimal
+ end
+
+ # Create the needed columns for acts_as_rated.
+ # To be used during migration, but can also be used in other places.
+ def add_ratings_columns
+ if !self.content_columns.find { |c| 'rating_count' == c.name }
+ self.connection.add_column table_name, :rating_count, :integer
+ self.connection.add_column table_name, :rating_total, :decimal
+ self.connection.add_column table_name, :rating_avg, :decimal
+ self.reset_column_information
+ end
+ end
+
+ # Remove the acts_as_rated specific columns added with add_ratings_columns
+ # To be used during migration, but can also be used in other places
+ def remove_ratings_columns
+ if self.content_columns.find { |c| 'rating_count' == c.name }
+ self.connection.drop_column table_name, :rating_count
+ self.connection.drop_column table_name, :rating_total
+ self.connection.drop_column table_name, :rating_avg
+ self.reset_column_information
+ end
+ end
+
+ # Create the ratings table
+ # === Options hash:
+ # * <tt>:with_rater</tt> - add the rated_id column
+ # * <tt>:table_name</tt> - use a table name other than ratings
+ # * <tt>:with_stats_table</tt> - create also a rating statistics table
+ # * <tt>:stats_table_name</tt> - the name of the rating statistics table. Defaults to :rating_statistics
+ # To be used during migration, but can also be used in other places
+ def create_ratings_table options = {}
+ with_rater = options[:with_rater] != false
+ name = options[:table_name] || :ratings
+ stats_table = options[:stats_table_name] || :rating_statistics if options[:with_stats_table]
+ self.connection.create_table(name) do |t|
+ t.column(:rater_id, :integer) unless !with_rater
+ t.column :rated_id, :integer
+ t.column :rated_type, :string
+ t.column :rating, :decimal
+ end
+
+ self.connection.add_index(name, :rater_id) unless !with_rater
+ self.connection.add_index name, [:rated_type, :rated_id]
+
+ unless stats_table.nil?
+ self.connection.create_table(stats_table) do |t|
+ t.column :rated_id, :integer
+ t.column :rated_type, :string
+ t.column :rating_count, :integer
+ t.column :rating_total, :decimal
+ t.column :rating_avg, :decimal
+ end
+
+ self.connection.add_index stats_table, [:rated_type, :rated_id]
+ end
+
+ end
+
+ # Drop the ratings table.
+ # === Options hash:
+ # * <tt>:table_name</tt> - the name of the ratings table, defaults to ratings
+ # * <tt>:with_stats_table</tt> - remove the special rating statistics as well
+ # * <tt>:stats_table_name</tt> - the statistics table name. Defaults to :rating_statistics
+ # To be used during migration, but can also be used in other places
+ def drop_ratings_table options = {}
+ name = options[:table_name] || :ratings
+ stats_table = options[:stats_table_name] || :rating_statistics if options[:with_stats_table]
+ self.connection.drop_table name
+ self.connection.drop_table stats_table unless stats_table.nil?
+ end
+
+ # Find all ratings for a specific rater.
+ # Will raise an error if this acts_as_rated is without a rater.
+ def find_rated_by rater
+ rating_class = acts_as_rated_options[:rating_class].constantize
+ raise RateError, "The rater object must be the one used when defining acts_as_rated (or a descendent of it). other objects are not acceptable" if !(acts_as_rated_options[:rater_class].constantize === rater)
+ raise RateError, 'Cannot find_rated_by if not using a rater' if !rating_class.column_names.include? "rater_id"
+ raise RateError, "Rater must be an existing object with an id" if rater.id.nil?
+ rated_class = ActiveRecord::Base.send(:class_name_of_active_record_descendant, self).to_s
+ conds = [ 'rated_type = ? AND rater_id = ?', rated_class, rater.id ]
+ acts_as_rated_options[:rating_class].constantize.find(:all, :conditions => conds).collect {|r| r.rated_type.constantize.find_by_id r.rated.id }
+ end
+
+ # Find by rating - pass either a specific value or a range and the precision to calculate with
+ # * <tt>value</tt> - the value to look for or a range
+ # * <tt>precision</tt> - number of decimal digits to round to. Default to 10. Use 0 for integer numbers comparision
+ def find_by_rating value, precision = 10
+ rating_class = acts_as_rated_options[:rating_class].constantize
+ if column_names.include? "rating_avg"
+ if Range === value
+ conds = [ 'round(rating_avg, ?) >= ? AND round(rating_avg, ?) <= ?',
+ precision.to_i, connection.quote(value.begin), precision.to_i, connection.quote(value.end) ]
+ else
+ conds = [ 'round(rating_avg, ?) = ?', precision.to_i, connection.quote(value) ]
+ end
+ find :all, :conditions => conds
+ else
+ base_sql = <<-EOS
+ select #{table_name}.*,round(COALESCE(average,0), #{precision.to_i}) AS rating_average from #{table_name} left outer join
+ (select avg(rating) as average, rated_id
+ from #{rating_class.table_name}
+ where rated_type = '#{class_name}'
+ group by rated_id) as rated
+ on rated_id=id
+ EOS
+ if Range === value
+ where_part = " WHERE round(COALESCE(average,0), #{precision.to_i}) >= #{connection.quote(value.begin)}
+ AND round(COALESCE(average,0), #{precision.to_i}) <= #{connection.quote(value.end)}"
+ else
+ where_part = " WHERE round(COALESCE(average,0), #{precision.to_i}) = #{connection.quote(value)}"
+ end
+
+ find_by_sql base_sql + where_part
+ end
+ end
+ end
+
+ end
+ end
+end
+
+
+ActiveRecord::Base.send :include, ActiveRecord::Acts::Rated
+
32 test/abstract_unit.rb
@@ -0,0 +1,32 @@
+$:.unshift(File.dirname(__FILE__) + '/../lib')
+
+
+require 'test/unit'
+require File.expand_path(File.join(File.dirname(__FILE__), '../../../../config/environment.rb'))
+require 'active_record/fixtures'
+
+config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
+ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
+ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'postgresql'])
+load(File.dirname(__FILE__) + "/schema.rb") unless ENV['NO_SCHEMA_LOAD'] == 'true'
+
+Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/"
+$LOAD_PATH.unshift(Test::Unit::TestCase.fixture_path)
+
+class Test::Unit::TestCase #:nodoc:
+ def create_fixtures(*table_names)
+ if block_given?
+ Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names) { yield }
+ else
+ Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names)
+ end
+ end
+
+ # Turn off transactional fixtures if you're working with MyISAM tables in MySQL
+ self.use_transactional_fixtures = false #true
+
+ # Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david)
+ self.use_instantiated_fixtures = false
+
+ # Add more helper methods to be used by all tests here...
+end
21 test/database.yml
@@ -0,0 +1,21 @@
+sqlite:
+ adapter: sqlite
+ dbfile: acts_as_rated_plugin.sqlite.db
+
+sqlite3:
+ adapter: sqlite3
+ dbfile: acts_as_rated_plugin.sqlite3.db
+
+mysql:
+ adapter: mysql
+ host: localhost
+ username: rails
+ password:
+ database: acts_as_rated_plugin_test
+
+postgresql:
+ adapter: postgresql
+ username: postgres
+ password: postgres
+ database: acts_as_rated_plugin_test
+ min_messages: error
63 test/dummy_classes.rb
@@ -0,0 +1,63 @@
+
+class User < ActiveRecord::Base
+end
+
+class Worker < ActiveRecord::Base
+ set_table_name 'users'
+end
+
+class Movie < ActiveRecord::Base
+ acts_as_rated
+end
+
+class Film < ActiveRecord::Base
+ set_table_name 'movies'
+ acts_as_rated :rating_range => 1..5
+end
+
+class Book < ActiveRecord::Base
+ acts_as_rated :rater_class => 'Worker'
+end
+
+class NoRaterRating < ActiveRecord::Base
+ belongs_to :rated, :polymorphic => true
+end
+
+class StatsRating < ActiveRecord::Base
+ belongs_to :rated, :polymorphic => true
+ belongs_to :rater, :class_name => 'User', :foreign_key => :rater_id
+end
+
+class MyStatsRating < ActiveRecord::Base
+ belongs_to :rated, :polymorphic => true
+ belongs_to :rater, :class_name => 'User', :foreign_key => :rater_id
+end
+
+class Car < ActiveRecord::Base
+ acts_as_rated :rating_class => 'NoRaterRating', :no_rater => true
+end
+
+class Mechanic < ActiveRecord::Base
+ set_table_name 'users'
+end
+
+class Truck < ActiveRecord::Base
+ set_table_name 'cars'
+ acts_as_rated :rating_class => 'NoRaterRating', :no_rater => true, :rater_class => 'Mechanic'
+end
+
+class Video < ActiveRecord::Base
+ acts_as_rated :with_stats_table => true, :rating_class => 'StatsRating'
+end
+
+
+class MyStatistic < ActiveRecord::Base
+ belongs_to :rated, :polymorphic => true
+end
+
+class Tape < ActiveRecord::Base
+ set_table_name 'videos'
+ acts_as_rated :with_stats_table => true, :stats_class => 'MyStatistic', :rating_class => 'MyStatsRating'
+end
+
+
20 test/fixtures/books.yml
@@ -0,0 +1,20 @@
+alice_in_wonderland:
+ id: 1
+ title: Alice in Wonderland
+
+the_lord_of_the_rings:
+ id: 2
+ title: The Lord of the Rings
+
+shogun:
+ id: 3
+ title: Shogun
+
+catch_22:
+ id: 4
+ title: Catch 22
+
+animal_farm:
+ id: 5
+ title: Aminal Farm
+
35 test/fixtures/cars.yml
@@ -0,0 +1,35 @@
+camry:
+ id: 1
+ title: Toyota Camry
+ rating_total: 9
+ rating_count: 3
+ rating_avg: 3
+
+carrera:
+ id: 2
+ title: Carrera
+ rating_total: 21
+ rating_count: 5
+ rating_avg: 4.2
+
+golf:
+ id: 3
+ title: VW Golf
+ rating_total: 7
+ rating_count: 2
+ rating_avg: 3.5
+
+bug:
+ id: 4
+ title: VW Bug
+ rating_total: 4
+ rating_count: 1
+ rating_avg: 4
+
+expedition:
+ id: 5
+ title: Ford Expedition
+ rating_total: 0
+ rating_count: 0
+ rating_avg: 0
+
52 test/fixtures/migrations/001_add_rating_tables.rb
@@ -0,0 +1,52 @@
+class AddRatingTables < ActiveRecord::Migration
+ def self.up
+ ActiveRecord::Base.create_ratings_table
+ ActiveRecord::Base.create_ratings_table :with_rater => false, :table_name => 'no_rater_ratings'
+ ActiveRecord::Base.create_ratings_table :with_stats_table => true, :table_name => 'stats_ratings'
+ ActiveRecord::Base.create_ratings_table :with_stats_table => true, :table_name => 'my_stats_ratings', :stats_table_name => 'my_statistics'
+
+ # Movies table has the columns for the ratings added
+ create_table(:movies) do |t|
+ t.column :title, :text
+ Movie.generate_ratings_columns t
+ end
+
+ # Books table doesn't have the columns for ratings added
+ create_table(:books) do |t|
+ t.column :title, :text
+ end
+
+ # Cars table has the columns for the ratings added, but is used for testing with no rater
+ create_table(:cars) do |t|
+ t.column :title, :text
+ end
+ Car.add_ratings_columns
+
+ # Videos table has the ratings columns added as part of the table creation
+ create_table(:videos) do |t|
+ t.column :title, :text
+ end
+
+ # We need a users table
+ create_table(:users) do |t|
+ t.column :title, :text
+ end
+
+ end
+
+ def self.down
+ Movie.remove_ratings_columns
+ Car.remove_ratings_columns
+
+ drop_table :movies rescue nil
+ drop_table :books rescue nil
+ drop_table :users rescue nil
+ drop_table :cars rescue nil
+ drop_table :videos rescue nil
+
+ ActiveRecord::Base.drop_ratings_table
+ ActiveRecord::Base.drop_ratings_table :table_name => 'no_rater_ratings'
+ ActiveRecord::Base.drop_ratings_table :with_stats_table => true, :table_name => 'stats_ratings'
+ ActiveRecord::Base.drop_ratings_table :with_stats_table => true, :table_name => 'my_stats_ratings', :stats_table_name => 'my_statistics'
+ end
+end
36 test/fixtures/movies.yml
@@ -0,0 +1,36 @@
+gone_with_the_wind:
+ id: 1
+ title: Gone With The Wind
+ rating_total: 13
+ rating_count: 3
+ rating_avg: 4.3333333333
+
+oz:
+ id: 2
+ title: The Wizard of Oz
+ rating_total: 15
+ rating_count: 3
+ rating_avg: 5
+
+rambo_3:
+ id: 3
+ title: Rambo 3
+ rating_total: 4
+ rating_count: 4
+ rating_avg: 1
+
+phantom:
+ id: 4
+ title: Phantom Menace
+ rating_total: 16
+ rating_count: 5
+ rating_avg: 3.2
+
+crash:
+ id: 5
+ title: Crash
+ rating_total: 0
+ rating_count: 0
+ rating_avg: 0
+
+
32 test/fixtures/my_statistics.yml
@@ -0,0 +1,32 @@
+on_a_golden_pond:
+ id: 1
+ rated_id: 1
+ rated_type: Tape
+ rating_total: 13
+ rating_count: 3
+ rating_avg: 4.3333333333
+
+fame:
+ id: 2
+ rated_id: 2
+ rated_type: Tape
+ rating_total: 15
+ rating_count: 3
+ rating_avg: 5
+
+ten:
+ id: 3
+ rated_id: 3
+ rated_type: Tape
+ rating_total: 4
+ rating_count: 4
+ rating_avg: 1
+
+fields_of_dreams:
+ id: 4
+ rated_id: 4
+ rated_type: Tape
+ rating_total: 16
+ rating_count: 5
+ rating_avg: 3.2
+
106 test/fixtures/my_stats_ratings.yml
@@ -0,0 +1,106 @@
+on_a_golden_pond_1:
+ id: 1001
+ rated_id: 1
+ rated_type: Tape
+ rater_id: 1
+ rating: 4
+
+on_a_golden_pond_2:
+ id: 1002
+ rated_id: 1
+ rated_type: Tape
+ rater_id: 2
+ rating: 4
+
+on_a_golden_pond_3:
+ id: 1003
+ rated_id: 1
+ rated_type: Tape
+ rater_id: 3
+ rating: 5
+
+fame_1:
+ id: 1004
+ rated_id: 2
+ rated_type: Tape
+ rater_id: 3
+ rating: 5
+
+fame_2:
+ id: 1005
+ rated_id: 2
+ rated_type: Tape
+ rater_id: 4
+ rating: 5
+
+fame_3:
+ id: 1006
+ rated_id: 2
+ rated_type: Tape
+ rater_id: 5
+ rating: 5
+
+ten_1:
+ id: 1007
+ rated_id: 3
+ rated_type: Tape
+ rater_id: 1
+ rating: 1
+
+ten_2:
+ id: 1008
+ rated_id: 3
+ rated_type: Tape
+ rater_id: 2
+ rating: 1
+
+ten_3:
+ id: 1009
+ rated_id: 3
+ rated_type: Tape
+ rater_id: 3
+ rating: 1
+
+ten_4:
+ id: 1010
+ rated_id: 3
+ rated_type: Tape
+ rater_id: 4
+ rating: 1
+
+fields_of_dreams_1:
+ id: 1011
+ rated_id: 4
+ rated_type: Tape
+ rater_id: 1
+ rating: 3
+
+fields_of_dreams_2:
+ id: 1012
+ rated_id: 4
+ rated_type: Tape
+ rater_id: 2
+ rating: 3
+
+fields_of_dreams_3:
+ id: 1013
+ rated_id: 4
+ rated_type: Tape
+ rater_id: 3
+ rating: 5
+
+fields_of_dreams_4:
+ id: 1014
+ rated_id: 4
+ rated_type: Tape
+ rater_id: 4
+ rating: 3
+
+fields_of_dreams_5:
+ id: 1015
+ rated_id: 4
+ rated_type: Tape
+ rater_id: 5
+ rating: 2
+
+
132 test/fixtures/no_rater_ratings.yml
@@ -0,0 +1,132 @@
+camry_1:
+ id: 1001
+ rated_id: 1
+ rated_type: Car
+ rating: 3
+
+camry_2:
+ id: 1002
+ rated_id: 1
+ rated_type: Car
+ rating: 4
+
+camry_3:
+ id: 1003
+ rated_id: 1
+ rated_type: Car
+ rating: 2
+
+carrera_1:
+ id: 1004
+ rated_id: 2
+ rated_type: Car
+ rating: 5
+
+carrera_2:
+ id: 1005
+ rated_id: 2
+ rated_type: Car
+ rating: 5
+
+carrera_3:
+ id: 1006
+ rated_id: 2
+ rated_type: Car
+ rating: 3
+
+carrera_4:
+ id: 1007
+ rated_id: 2
+ rated_type: Car
+ rating: 3
+
+carrera_5:
+ id: 1008
+ rated_id: 2
+ rated_type: Car
+ rating: 5
+
+golf_1:
+ id: 1009
+ rated_id: 3
+ rated_type: Car
+ rating: 3
+
+golf_2:
+ id: 10010
+ rated_id: 3
+ rated_type: Car
+ rating: 4
+
+bug_1:
+ id: 10011
+ rated_id: 4
+ rated_type: Car
+ rating: 4
+
+truck_camry_1:
+ id: 11001
+ rated_id: 1
+ rated_type: Truck
+ rating: 3
+
+truck_camry_2:
+ id: 11002
+ rated_id: 1
+ rated_type: Truck
+ rating: 4
+
+truck_camry_3:
+ id: 11003
+ rated_id: 1
+ rated_type: Truck
+ rating: 2
+
+truck_carrera_1:
+ id: 11004
+ rated_id: 2
+ rated_type: Truck
+ rating: 5
+
+truck_carrera_2:
+ id: 11005
+ rated_id: 2
+ rated_type: Truck
+ rating: 5
+
+truck_carrera_3:
+ id: 11006
+ rated_id: 2
+ rated_type: Truck
+ rating: 3
+
+truck_carrera_4:
+ id: 11007
+ rated_id: 2
+ rated_type: Truck
+ rating: 3
+
+truck_carrera_5:
+ id: 11008
+ rated_id: 2
+ rated_type: Truck
+ rating: 5
+
+truck_golf_1:
+ id: 11009
+ rated_id: 3
+ rated_type: Truck
+ rating: 3
+
+truck_golf_2:
+ id: 11010
+ rated_id: 3
+ rated_type: Truck
+ rating: 4
+
+truck_bug_1:
+ id: 11011
+ rated_id: 4
+ rated_type: Truck
+ rating: 4
+
32 test/fixtures/rating_statistics.yml
@@ -0,0 +1,32 @@
+on_a_golden_pond:
+ id: 1
+ rated_id: 1
+ rated_type: Video
+ rating_total: 13
+ rating_count: 3
+ rating_avg: 4.3333333333
+
+fame:
+ id: 2
+ rated_id: 2
+ rated_type: Video
+ rating_total: 15
+ rating_count: 3
+ rating_avg: 5
+
+ten:
+ id: 3
+ rated_id: 3
+ rated_type: Video
+ rating_total: 4
+ rating_count: 4
+ rating_avg: 1
+
+fields_of_dreams:
+ id: 4
+ rated_id: 4
+ rated_type: Video
+ rating_total: 16
+ rating_count: 5
+ rating_avg: 3.2
+
350 test/fixtures/ratings.yml
@@ -0,0 +1,350 @@
+movie_1:
+ id: 1
+ rater_id: 1
+ rated_id: 1
+ rated_type: Movie
+ rating: 4
+
+movie_2:
+ id: 2
+ rater_id: 2
+ rated_id: 1
+ rated_type: Movie
+ rating: 4
+
+movie_3:
+ id: 3
+ rater_id: 3
+ rated_id: 1
+ rated_type: Movie
+ rating: 5
+
+movie_4:
+ id: 4
+ rater_id: 1
+ rated_id: 2
+ rated_type: Movie
+ rating: 5
+
+movie_5:
+ id: 5
+ rater_id: 2
+ rated_id: 2
+ rated_type: Movie
+ rating: 5
+
+movie_6:
+ id: 6
+ rater_id: 4
+ rated_id: 2
+ rated_type: Movie
+ rating: 5
+
+movie_7:
+ id: 7
+ rater_id: 1
+ rated_id: 3
+ rated_type: Movie
+ rating: 1
+
+movie_8:
+ id: 8
+ rater_id: 2
+ rated_id: 3
+ rated_type: Movie
+ rating: 1
+
+movie_9:
+ id: 9
+ rater_id: 3
+ rated_id: 3
+ rated_type: Movie
+ rating: 1
+
+movie_10:
+ id: 10
+ rater_id: 5
+ rated_id: 3
+ rated_type: Movie
+ rating: 1
+
+movie_11:
+ id: 11
+ rater_id: 1
+ rated_id: 4
+ rated_type: Movie
+ rating: 2
+
+movie_12:
+ id: 12
+ rater_id: 2
+ rated_id: 4
+ rated_type: Movie
+ rating: 2
+
+movie_13:
+ id: 13
+ rater_id: 3
+ rated_id: 4
+ rated_type: Movie
+ rating: 4
+
+movie_14:
+ id: 14
+ rater_id: 4
+ rated_id: 4
+ rated_type: Movie
+ rating: 3
+
+movie_15:
+ id: 15
+ rater_id: 5
+ rated_id: 4
+ rated_type: Movie
+ rating: 5
+
+book_1:
+ id: 16
+ rater_id: 1
+ rated_id: 1
+ rated_type: Book
+ rating: 4
+
+book_2:
+ id: 17
+ rater_id: 2
+ rated_id: 1
+ rated_type: Book
+ rating: 3
+
+book_3:
+ id: 18
+ rater_id: 3
+ rated_id: 1
+ rated_type: Book
+ rating: 1
+
+book_4:
+ id: 19
+ rater_id: 4
+ rated_id: 1
+ rated_type: Book
+ rating: 2
+
+book_5:
+ id: 20
+ rater_id: 5
+ rated_id: 1
+ rated_type: Book
+ rating: 5
+
+book_6:
+ id: 21
+ rater_id: 1
+ rated_id: 2
+ rated_type: Book
+ rating: 5
+
+book_7:
+ id: 22
+ rater_id: 2
+ rated_id: 2
+ rated_type: Book
+ rating: 4
+
+book_8:
+ id: 23
+ rater_id: 3
+ rated_id: 2
+ rated_type: Book
+ rating: 1
+
+book_9:
+ id: 24
+ rater_id: 4
+ rated_id: 2
+ rated_type: Book
+ rating: 2
+
+book_10:
+ id: 25
+ rater_id: 2
+ rated_id: 3
+ rated_type: Book
+ rating: 3
+
+book_11:
+ id: 26
+ rater_id: 3
+ rated_id: 3
+ rated_type: Book
+ rating: 4
+
+book_12:
+ id: 27
+ rater_id: 4
+ rated_id: 3
+ rated_type: Book
+ rating: 3
+
+book_13:
+ id: 28
+ rater_id: 5
+ rated_id: 3
+ rated_type: Book
+ rating: 5
+
+book_14:
+ id: 29
+ rater_id: 1
+ rated_id: 4
+ rated_type: Book
+ rating: 5
+
+book_15:
+ id: 30
+ rater_id: 2
+ rated_id: 4
+ rated_type: Book
+ rating: 2
+
+book_16:
+ id: 31
+ rater_id: 3
+ rated_id: 4
+ rated_type: Book
+ rating: 4
+
+book_17:
+ id: 32
+ rater_id: 1
+ rated_id: 5
+ rated_type: Book
+ rating: 1
+
+book_18:
+ id: 33
+ rater_id: 2
+ rated_id: 5
+ rated_type: Book
+ rating: 1
+
+book_19:
+ id: 34
+ rater_id: 3
+ rated_id: 5
+ rated_type: Book
+ rating: 5
+
+book_20:
+ id: 35
+ rater_id: 4
+ rated_id: 5
+ rated_type: Book
+ rating: 5
+
+film_1:
+ id: 1001
+ rater_id: 1
+ rated_id: 1
+ rated_type: Film
+ rating: 4
+
+film_2:
+ id: 1002
+ rater_id: 2
+ rated_id: 1
+ rated_type: Film
+ rating: 4
+
+film_3:
+ id: 1003
+ rater_id: 3
+ rated_id: 1
+ rated_type: Film
+ rating: 5
+
+film_4:
+ id: 1004
+ rater_id: 1
+ rated_id: 2
+ rated_type: Film
+ rating: 5
+
+film_5:
+ id: 1005
+ rater_id: 2
+ rated_id: 2
+ rated_type: Film
+ rating: 5
+
+film_6:
+ id: 1006
+ rater_id: 4
+ rated_id: 2
+ rated_type: Film
+ rating: 5
+
+film_7:
+ id: 1007
+ rater_id: 1
+ rated_id: 3
+ rated_type: Film
+ rating: 1
+
+film_8:
+ id: 1008
+ rater_id: 2
+ rated_id: 3
+ rated_type: Film
+ rating: 1
+
+film_9:
+ id: 1009
+ rater_id: 3
+ rated_id: 3
+ rated_type: Film
+ rating: 1
+
+film_10:
+ id: 1010
+ rater_id: 5
+ rated_id: 3
+ rated_type: Film
+ rating: 1
+
+film_11:
+ id: 1011
+ rater_id: 1
+ rated_id: 4
+ rated_type: Film
+ rating: 2
+
+film_12:
+ id: 1012
+ rater_id: 2
+ rated_id: 4
+ rated_type: Film
+ rating: 2
+
+film_13:
+ id: 1013
+ rater_id: 3
+ rated_id: 4
+ rated_type: Film
+ rating: 4
+
+film_14:
+ id: 1014
+ rater_id: 4
+ rated_id: 4
+ rated_type: Film
+ rating: 3
+
+film_15:
+ id: 1015
+ rater_id: 5
+ rated_id: 4
+ rated_type: Film
+ rating: 5
+
105 test/fixtures/stats_ratings.yml
@@ -0,0 +1,105 @@
+on_a_golden_pond_1:
+ id: 1001
+ rated_id: 1
+ rated_type: Video
+ rater_id: 1
+ rating: 4
+
+on_a_golden_pond_2:
+ id: 1002
+ rated_id: 1
+ rated_type: Video
+ rater_id: 2
+ rating: 4
+
+on_a_golden_pond_3:
+ id: 1003
+ rated_id: 1
+ rated_type: Video
+ rater_id: 3
+ rating: 5
+
+fame_1:
+ id: 1004
+ rated_id: 2
+ rated_type: Video
+ rater_id: 3
+ rating: 5
+
+fame_2:
+ id: 1005
+ rated_id: 2
+ rated_type: Video
+ rater_id: 4
+ rating: 5
+
+fame_3:
+ id: 1006
+ rated_id: 2
+ rated_type: Video
+ rater_id: 5
+ rating: 5
+
+ten_1:
+ id: 1007
+ rated_id: 3
+ rated_type: Video
+ rater_id: 1
+ rating: 1
+
+ten_2:
+ id: 1008
+ rated_id: 3
+ rated_type: Video
+ rater_id: 2
+ rating: 1
+
+ten_3:
+ id: 1009
+ rated_id: 3
+ rated_type: Video
+ rater_id: 3
+ rating: 1
+
+ten_4:
+ id: 1010
+ rated_id: 3
+ rated_type: Video
+ rater_id: 4
+ rating: 1
+
+fields_of_dreams_1:
+ id: 1011
+ rated_id: 4
+ rated_type: Video
+ rater_id: 1
+ rating: 3
+
+fields_of_dreams_2:
+ id: 1012
+ rated_id: 4
+ rated_type: Video
+ rater_id: 2
+ rating: 3
+
+fields_of_dreams_3:
+ id: 1013
+ rated_id: 4
+ rated_type: Video
+ rater_id: 3
+ rating: 5
+
+fields_of_dreams_4:
+ id: 1014
+ rated_id: 4
+ rated_type: Video
+ rater_id: 4
+ rating: 3
+
+fields_of_dreams_5:
+ id: 1015
+ rated_id: 4
+ rated_type: Video
+ rater_id: 5
+ rating: 2
+
24 test/fixtures/users.yml
@@ -0,0 +1,24 @@
+john:
+ id: 1
+ title: john
+
+bill:
+ id: 2
+ title: bill
+
+sarah:
+ id: 3
+ title: sarah
+
+jane:
+ id: 4
+ title: jane
+
+jill:
+ id: 5
+ title: jill
+
+jack:
+ id: 6
+ title: jack
+
16 test/fixtures/videos.yml
@@ -0,0 +1,16 @@
+on_a_golden_pond:
+ id: 1
+ title: On a Golden Pond
+
+fame:
+ id: 2
+ title: Fame
+
+ten:
+ id: 3
+ title: Ten
+
+fields_of_dreams:
+ id: 4
+ title: Fields of Dreams
+
71 test/migration_test.rb
@@ -0,0 +1,71 @@
+ENV['NO_SCHEMA_LOAD'] = 'true'
+require File.join(File.dirname(__FILE__), 'abstract_unit')
+require File.join(File.dirname(__FILE__), 'dummy_classes')
+
+if ActiveRecord::Base.connection.supports_migrations?
+ class MigrationTest < Test::Unit::TestCase
+ self.use_transactional_fixtures = false
+
+ # Defeat table creation!
+ def create_fixtures(*table_names)
+ end
+
+ def setup
+ teardown # Same in our case...
+ end
+
+ def teardown
+ ActiveRecord::Base.connection.initialize_schema_information
+ ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0"
+
+ [Movie, Book, Car, NoRaterRating, Rating, User, Video, RatingStatistic, MyStatistic, StatsRating, MyStatsRating].each do |c|
+ c.connection.drop_table c.table_name rescue nil
+ c.reset_column_information
+ end
+ end
+
+ # Add ratings table AND add the special stats table
+ def test_add_ratings_table_migration
+ verify_tables_do_not_exist
+
+ # up we go...
+ ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
+ [Book, Movie, Car, Video, Truck, Tape, Film, User, Mechanic].each do |c|
+ t = nil
+ assert_nothing_raised { t = c.create }
+ assert_respond_to t, :title
+ assert_respond_to t, :rating_average unless [User, Mechanic].include?(c)
+ assert t.attributes.include?('rating_avg') unless [User, Mechanic, Book, Video, Tape].include?(c)
+ assert !t.attributes.include?('rating_avg') if [User, Mechanic, Book, Video, Tape].include?(c)
+ end
+ r = nil
+ assert_nothing_raised { r = Rating.create }
+ assert_respond_to r, :rater_id
+ n = nil
+ assert_nothing_raised { n = NoRaterRating.create }
+ assert_raises(NoMethodError) { n.rater_id }
+ assert_respond_to n, :rating
+ s = nil
+ assert_nothing_raised { s = RatingStatistic.create }
+ assert_respond_to s, :rated_id
+ assert_respond_to s, :rated_type
+ assert_respond_to s, :rating_avg
+ m = nil
+ assert_nothing_raised { m = MyStatistic.create }
+ assert_respond_to m, :rated_id
+ assert_respond_to m, :rated_type
+ assert_respond_to m, :rating_avg
+
+ # down again
+ ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/')
+ verify_tables_do_not_exist
+ end
+
+ def verify_tables_do_not_exist
+ [Book, Movie, Car, User, Rating, Video, NoRaterRating, RatingStatistic, MyStatistic, StatsRating, MyStatsRating].each do |c|
+ assert_raises(ActiveRecord::StatementInvalid) { c.create }
+ end
+ end
+
+ end
+end
383 test/rated_test.rb
@@ -0,0 +1,383 @@
+require File.join(File.dirname(__FILE__), 'abstract_unit')
+require File.join(File.dirname(__FILE__), 'dummy_classes')
+
+class RatedTest < Test::Unit::TestCase
+ fixtures :cars, :movies, :books, :users, :ratings, :no_rater_ratings, :videos, :stats_ratings, :my_stats_ratings, :rating_statistics, :my_statistics
+
+ def test_rate
+ # Regular one...
+ m = movies(:gone_with_the_wind)
+ check_average m, 4.33
+ m.rate 1, users(:sarah)
+ check_average m, 3
+ m = Movie.new :title => 'King Kong'
+ m.rate 4, users(:john)
+ assert m.new_record?
+ assert_equal 4, m.rating_average
+ assert_equal 1, m.rating_count
+ assert_equal 4, m.rating_total
+ assert m.save
+ m = Movie.find m.id
+ assert_equal 4, m.rating_average
+ assert_equal 1, m.rating_count
+ assert_equal 4, m.rating_total
+ m.rate 6, users(:bill)
+ m.rate 2, users(:sarah)
+ assert_equal 4, m.rating_average
+ assert_equal 3, m.rating_count
+ assert_equal 12, m.rating_total
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { m.rate 6 }
+
+ # Ratring with norating columns
+ b = books(:shogun)
+ assert_raise(NoMethodError) { b.rating_total }
+ check_average b, 3.75
+ b.rate 10, Worker.find(users(:jane).id)
+ check_average b, 5.5
+
+ # Rating with no rater
+ c = cars(:bug)
+ check_average c, 4
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { c.rate 10, users(:jill) }
+ c.rate 10
+ c.rate 10
+ c.rate 10
+ c.rate 10
+ c.rate 10
+ check_average c, 9
+
+ # Ranged ratings
+ f = Film.find :first, :order => 'title'
+ assert_equal 'Crash', f.title
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { f.rate 0, users(:sarah) }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { f.rate 5.0001, users(:sarah) }
+ f.rate 1, users(:sarah)
+ f.rate 5, users(:jane)
+ check_average f, 3
+
+ # rating with an external statistics table
+ v = videos(:ten)
+ rc = v.ratings.count
+ assert_raise(NoMethodError) { v.rating_total }
+ check_average v, 1
+ v.rate 9, users(:jane)
+ check_average v, 3
+ assert_equal rc, v.ratings.count
+ v.rate 3, users(:jack)
+ check_average v, 3
+ assert_equal rc + 1, v.ratings.count
+ t = Tape.find(videos(:fame).id)
+ rc = t.ratings.count
+ assert_raise(NoMethodError) { t.rating_total }
+ check_average t, 5
+ t.rate 2, users(:jane)
+ check_average t, 4
+ assert_equal rc, t.ratings.count
+ t.rate 8, users(:jack)
+ check_average t, 5
+ assert_equal rc + 1, t.ratings.count
+ v = Video.new :title => 'Hair'
+ v.save
+ check_average v, 0
+ v.rate 4, users(:bill)
+ check_average v, 4
+ t = Tape.new :title => 'Friends'
+ t.save
+ check_average t, 0
+ t.rate 4, users(:bill)
+ check_average t, 4
+ t.rate 6, users(:jill)
+ check_average t, 5
+
+ # Rating with the wrong rater class or one that's not initialized
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { b.rate 10, users(:jane) }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { b.rate 10, 3 }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { b.rate 10, Worker.new }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { b.rate 10 }
+ end
+
+ def test_unrate
+ # Regular one...
+ m = movies(:gone_with_the_wind)
+ check_average m, 4.33
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { m.unrate nil }
+ m.unrate users(:john)
+ m.unrate users(:bill)
+ m.unrate users(:sarah)
+ m.unrate users(:jane)
+ m.unrate users(:jill)
+ check_average m, 0
+ m = Movie.new :title => 'King Kong'
+ m.rate 4, users(:john)
+ m.rate 4, users(:bill)
+ assert m.new_record?
+ assert_equal 4, m.rating_average
+ assert_equal 2, m.rating_count
+ assert_equal 8, m.rating_total
+ assert m.save
+ m = Movie.find m.id
+ assert_equal 4, m.rating_average
+ assert_equal 2, m.rating_count
+ assert_equal 8, m.rating_total
+ m.unrate users(:john)
+ assert_equal 4, m.rating_average
+ assert_equal 1, m.rating_count
+ assert_equal 4, m.rating_total
+
+ # Unrating with norating columns
+ b = books(:shogun)
+ assert_raise(NoMethodError) { b.ratings[0].rating_total }
+ check_average b, 3.75
+ b.unrate Worker.find(users(:bill).id)
+ check_average b, 4
+
+ # Unrating with external stats table
+ v = videos(:fields_of_dreams)
+ check_average v, 3.2
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { v.unrate nil }
+ v.unrate users(:john)
+ v.unrate users(:bill)
+ v.unrate users(:sarah)
+ v.unrate users(:jane)
+ v.unrate users(:jill)
+ check_average v, 0
+ v = Video.new :title => 'King Kong'
+ assert v.new_record?
+ assert v.save
+ v = Video.find v.id
+ v.rate 4, users(:john)
+ v.rate 4, users(:bill)
+ assert_equal 4, v.rating_average
+ assert_equal 2, v.rated_count
+ assert_equal 8, v.rated_total
+ v.unrate users(:john)
+ assert_equal 4, v.rating_average
+ assert_equal 1, v.rated_count
+ assert_equal 4, v.rated_total
+
+ t = Tape.find(videos(:fields_of_dreams).id)
+ check_average t, 3.2
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { t.unrate nil }
+ t.unrate users(:john)
+ t.unrate users(:bill)
+ t.unrate users(:sarah)
+ t.unrate users(:jane)
+ t.unrate users(:jill)
+ check_average t, 0
+ t = Tape.new :title => 'Scream'
+ assert t.save
+ t.rate 4, users(:john)
+ t.rate 6, users(:bill)
+ t = Tape.find t.id
+ assert_equal 5, t.rating_average
+ assert_equal 2, t.rated_count
+ assert_equal 10, t.rated_total
+ t.unrate users(:john)
+ assert_equal 6, t.rating_average
+ assert_equal 1, t.rated_count
+ assert_equal 6, t.rated_total
+
+ # No unrating with no rater
+ c = cars(:bug)
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { c.unrate users(:jill) }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { c.unrate nil }
+
+ # Check unrater validity
+ b = books(:shogun)
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { b.unrate users(:jane) }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { b.unrate 3 }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { b.unrate Worker.new }
+ end
+
+ def test_rated?
+ [Car, Movie, Book, Video, Tape, Truck, Film].each do |c|
+ # First check all the ones we have in the fixtures
+ c.find(:all).each do |o|
+ assert o.rated? if o.rated_count > 0
+ end
+
+ # Then create some new ones and test those as well
+ o = c.new(:title => 'Test Title')
+ assert o.save
+ assert !o.rated?
+ o.rate 4, Worker.find(users(:john).id) if [Book].include? c
+ o.rate 4, users(:john) if [Movie, Video, Tape, Film].include? c
+ o.rate 4 if [Car, Truck].include? c
+ #o.reload
+ assert o.rated?
+ end
+ end
+
+ def test_rating_average
+ m = movies(:gone_with_the_wind)
+ check_average m, 4.33
+ m = movies(:oz)
+ check_average m, 5
+ m = movies(:crash)
+ check_average m, 0
+ m.rate 3, users(:john)
+ m.rate 5, users(:bill)
+ check_average m, 4
+ m.rate 3, users(:bill)
+ check_average m, 3
+ m.unrate users(:bill)
+ check_average m, 3
+
+ c = cars(:camry)
+ check_average c, 3
+ c = cars(:bug)
+ check_average c, 4
+ c = cars(:expedition)
+ check_average c, 0
+ c.rate 3
+ c.rate 5
+ check_average c, 4
+ c.rate 3
+ check_average c, 3.66
+ end
+
+ def test_count
+ m = movies(:gone_with_the_wind)
+ assert_equal 3, m.rated_count
+ m.rate 4, users(:john)
+ m.rate 4, users(:bill)
+ m.rate 4, users(:sarah)
+ m.rate 4, users(:jane)
+ m.rate 4, users(:jill)
+ assert_equal 5, m.rated_count
+
+ c = cars(:expedition)
+ assert_equal 0, c.rated_count
+ c.rate 4
+ c.rate 4
+ c.rate 4
+ c.rate 4
+ c.rate 4
+ assert_equal 5, c.rated_count
+
+ b = books(:animal_farm)
+ assert_equal 4, b.rated_count
+ b.rate 4, Worker.find(users(:john).id)
+ b.rate 4, Worker.find(users(:bill).id)
+ b.rate 4, Worker.find(users(:sarah).id)
+ b.rate 4, Worker.find(users(:jane).id)
+ b.rate 4, Worker.find(users(:jill).id)
+ assert_equal 5, b.rated_count
+ end
+
+ def test_total
+ m = movies(:gone_with_the_wind)
+ assert_equal 13, m.rated_total
+ m.rate 4, users(:john)
+ m.rate 4, users(:bill)
+ m.rate 4, users(:sarah)
+ m.rate 4, users(:jane)
+ m.rate 4, users(:jill)
+ assert_equal 20, m.rated_total
+
+ c = cars(:expedition)
+ assert_equal 0, c.rated_total
+ c.rate 4
+ c.rate 4
+ c.rate 4
+ c.rate 4
+ c.rate 4
+ assert_equal 20, c.rated_total
+
+ b = books(:animal_farm)
+ assert_equal 12, b.rated_total
+ b.rate 4, Worker.find(users(:john).id)
+ b.rate 4, Worker.find(users(:bill).id)
+ b.rate 4, Worker.find(users(:sarah).id)
+ b.rate 4, Worker.find(users(:jane).id)
+ b.rate 4, Worker.find(users(:jill).id)
+ assert_equal 20, b.rated_total
+ end
+
+ def test_find_by_rating
+ cs = Car.find_by_rating 0
+ assert_equal 1, cs.size
+ assert_equal 'Ford Expedition', cs[0].title
+ cs = Car.find_by_rating 3
+ assert_equal 1, cs.size
+ assert_equal 'Toyota Camry', cs[0].title
+ cs = Car.find_by_rating 3.5
+ assert_equal 1, cs.size
+ assert_equal 'VW Golf', cs[0].title
+ cs = Car.find_by_rating 4, 0
+ check_returned_array cs, ['VW Golf', 'Carrera', 'VW Bug']
+ cs = Car.find_by_rating 3..4, 0
+ check_returned_array cs, ['Toyota Camry', 'VW Golf', 'Carrera', 'VW Bug']
+ cs = Car.find_by_rating 3..4
+ check_returned_array cs, ['Toyota Camry', 'VW Golf', 'VW Bug']
+ fs = Film.find_by_rating 1..4, 0
+ check_returned_array fs, ["Rambo 3", "Gone With The Wind", "Phantom Menace"]
+ ms = Movie.find_by_rating 5
+ check_returned_array ms, ["The Wizard of Oz"]
+ bs = Book.find_by_rating 3..3.7
+ check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings", "Catch 22"]
+ bs = Book.find_by_rating 3..3.7, 0
+ check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings"]
+ bs = Book.find_by_rating 1..3, 0
+ check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings"]
+ bs = Book.find_by_rating 3, 0
+ check_returned_array bs, ["Alice in Wonderland", "Aminal Farm", "The Lord of the Rings"]
+ end
+
+ def test_find_rated_by
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { Car.find_rated_by 5 }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { Movie.find_rated_by nil }
+ assert_raise(ActiveRecord::Acts::Rated::RateError) { Movie.find_rated_by 1 }
+ ms = Movie.find_rated_by users(:john)
+ check_returned_array ms, ["Gone With The Wind", "The Wizard of Oz", "Phantom Menace", "Rambo 3"]
+ ms = Movie.find_rated_by users(:jack)
+ check_returned_array ms, []
+ m = Movie.new :title => 'Borat'
+ m.save
+ m.rate 5, users(:jack)
+ ms = Movie.find_rated_by users(:jack)
+ check_returned_array ms, ["Borat"]
+ bs = Book.find_rated_by Worker.find(users(:john).id)
+ check_returned_array bs, ["The Lord of the Rings", "Alice in Wonderland", "Catch 22", "Aminal Farm"]
+ fs = Film.find_rated_by users(:john)
+ check_returned_array fs, ["Gone With The Wind", "Phantom Menace", "The Wizard of Oz", "Rambo 3"]
+ f = Film.new :title => 'Kill Bill'
+ f.save
+ f.rate 4, users(:jill)
+ fs = Film.find_rated_by users(:jill)
+ check_returned_array fs, ["Rambo 3", "Phantom Menace", "Kill Bill"]
+ end
+
+ def test_associations
+ assert User.new.respond_to?(:ratings)
+ assert !Mechanic.new.respond_to?(:ratings)
+ assert Book.new.respond_to?(:ratings)
+ assert Book.new.respond_to?(:raters)
+ assert Car.new.respond_to?(:ratings)
+ assert !Car.new.respond_to?(:raters)
+ assert Truck.new.respond_to?(:ratings)
+ assert !Truck.new.respond_to?(:raters)
+ end
+
+ # This just test that the fixtures data makes sense
+ def test_all_fixtures
+ [Car, Movie, Book, Video, Tape, Truck, Film].each do |c|
+ c.find(:all).each do |o|
+ check_average o, o.rating_average
+ end
+ end
+ end
+
+ def check_average obj, value
+ assert_equal (value * 100).to_i, (obj.rating_average * 100).to_i
+ assert_equal (obj.ratings.average(:rating) * 100).to_i, (obj.rating_average * 100).to_i
+ end
+
+ def check_returned_array ar, expected_list
+ names = ar.collect {|e| e.title }
+ assert_equal expected_list.size, names.size
+ assert_equal [], names - expected_list
+ end
+
+end
+
72 test/schema.rb
@@ -0,0 +1,72 @@
+ActiveRecord::Schema.define(:version => 0) do
+
+ create_table :users, :force => true do |t|
+ t.column :title, :text
+ end
+
+ create_table :ratings, :force => true do |t|
+ t.column :rater_id, :integer
+ t.column :rated_id, :integer
+ t.column :rated_type, :string
+ t.column :rating, :decimal
+ end
+
+ create_table :stats_ratings, :force => true do |t|
+ t.column :rater_id, :integer
+ t.column :rated_id, :integer
+ t.column :rated_type, :string
+ t.column :rating, :decimal
+ end
+
+ create_table :my_stats_ratings, :force => true do |t|
+ t.column :rater_id, :integer
+ t.column :rated_id, :integer
+ t.column :rated_type, :string
+ t.column :rating, :decimal
+ end
+
+ create_table :no_rater_ratings, :force => true do |t|
+ t.column :rated_id, :integer
+ t.column :rated_type, :string
+ t.column :rating, :decimal
+ end
+
+ create_table :books, :force => true do |t|
+ t.column :title, :text
+ end
+
+ create_table :videos, :force => true do |t|
+ t.column :title, :text
+ end
+
+ create_table :movies, :force => true do |t|
+ t.column :title, :text
+ t.column :rating_count, :integer
+ t.column :rating_total, :decimal
+ t.column :rating_avg, :decimal
+ end
+
+ create_table :cars, :force => true do |t|
+ t.column :title, :text
+ t.column :rating_count, :integer
+ t.column :rating_total, :decimal
+ t.column :rating_avg, :decimal
+ end
+
+ create_table :rating_statistics, :force => true do |t|
+ t.column :rated_id, :integer
+ t.column :rated_type, :string
+ t.column :rating_count, :integer
+ t.column :rating_total, :decimal
+ t.column :rating_avg, :decimal
+ end
+
+ create_table :my_statistics, :force => true do |t|
+ t.column :rated_id, :integer
+ t.column :rated_type, :string
+ t.column :rating_count, :integer
+ t.column :rating_total, :decimal
+ t.column :rating_avg, :decimal
+ end
+
+end

1 comment on commit da069cb

@jdevine

line 152 of lib/acts_as_rated.rb should read:

return ((rating_statistic.rating_avg || 0) rescue 0) if acts_as_rated_options[:stats_class]
Please sign in to comment.
Something went wrong with that request. Please try again.