diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..5055eac --- /dev/null +++ b/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. diff --git a/README b/README new file mode 100644 index 0000000..488ab85 --- /dev/null +++ b/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. + +NOTE: 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 - comming soon + +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 + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..cf64171 --- /dev/null +++ b/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 diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..23dc275 --- /dev/null +++ b/init.rb @@ -0,0 +1,3 @@ +require 'acts_as_rated' + + diff --git a/lib/acts_as_rated.rb b/lib/acts_as_rated.rb new file mode 100644 index 0000000..a82d7d4 --- /dev/null +++ b/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 has_many :ratings association to the model for easy retrieval of the detailed ratings. + # * Adds a has_many :raters association to the onject, unless :no_rater is given as a configuration parameter. + # * Adds a has_many :ratings associations to the rater class. + # * Adds a has_one :rating_statistic association to the model, if :with_stats_table => true is given as a configuration param. + # + # === Options + # * :rating_class - + # 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: + # belongs_to :rated, :polymorphic => true and if using a rater (which is true in most cases, see below) also + # belongs_to :rater, :class_name => 'User', :foreign_key => :rater_id replace user with the rater class if needed. + # * :rater_class - + # 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. + # * :no_rater - + # 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 + # * :rating_range - + # A range object for the acceptable rating value range. Defaults to not limited + # * :with_stats_table - + # 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 + # * :stats_class - + # Class of the statics table model. Only needed if :with_stats_table is set to true. Default to RatingStat. + # This class need to have the following defined: belongs_to :rated, :polymorphic => true. + # And must make sure that it has the attributes rating_count, rating_total and rating_avg 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 + # + # * value - the value to rate by, if a rating range was specified will be checked that it is in range + # * rater - 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. + # * rater - 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: + # * :with_rater - add the rated_id column + # * :table_name - use a table name other than ratings + # * :with_stats_table - create also a rating statistics table + # * :stats_table_name - 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: + # * :table_name - the name of the ratings table, defaults to ratings + # * :with_stats_table - remove the special rating statistics as well + # * :stats_table_name - 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 + # * value - the value to look for or a range + # * precision - 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 + diff --git a/test/abstract_unit.rb b/test/abstract_unit.rb new file mode 100644 index 0000000..dd5d9a3 --- /dev/null +++ b/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 diff --git a/test/database.yml b/test/database.yml new file mode 100644 index 0000000..9783dce --- /dev/null +++ b/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 diff --git a/test/dummy_classes.rb b/test/dummy_classes.rb new file mode 100644 index 0000000..560f66b --- /dev/null +++ b/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 + + diff --git a/test/fixtures/books.yml b/test/fixtures/books.yml new file mode 100644 index 0000000..fdbd6dd --- /dev/null +++ b/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 + diff --git a/test/fixtures/cars.yml b/test/fixtures/cars.yml new file mode 100644 index 0000000..418bc08 --- /dev/null +++ b/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 + diff --git a/test/fixtures/migrations/001_add_rating_tables.rb b/test/fixtures/migrations/001_add_rating_tables.rb new file mode 100644 index 0000000..b635c90 --- /dev/null +++ b/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 diff --git a/test/fixtures/movies.yml b/test/fixtures/movies.yml new file mode 100644 index 0000000..7ec8e5f --- /dev/null +++ b/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 + + diff --git a/test/fixtures/my_statistics.yml b/test/fixtures/my_statistics.yml new file mode 100644 index 0000000..4b7ba4a --- /dev/null +++ b/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 + diff --git a/test/fixtures/my_stats_ratings.yml b/test/fixtures/my_stats_ratings.yml new file mode 100644 index 0000000..a38a31f --- /dev/null +++ b/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 + + diff --git a/test/fixtures/no_rater_ratings.yml b/test/fixtures/no_rater_ratings.yml new file mode 100644 index 0000000..95b2a2a --- /dev/null +++ b/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 + diff --git a/test/fixtures/rating_statistics.yml b/test/fixtures/rating_statistics.yml new file mode 100644 index 0000000..5a9b285 --- /dev/null +++ b/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 + diff --git a/test/fixtures/ratings.yml b/test/fixtures/ratings.yml new file mode 100644 index 0000000..382b938 --- /dev/null +++ b/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 + diff --git a/test/fixtures/stats_ratings.yml b/test/fixtures/stats_ratings.yml new file mode 100644 index 0000000..4cfaa74 --- /dev/null +++ b/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 + diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..d556e2f --- /dev/null +++ b/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 + diff --git a/test/fixtures/videos.yml b/test/fixtures/videos.yml new file mode 100644 index 0000000..82ca128 --- /dev/null +++ b/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 + diff --git a/test/migration_test.rb b/test/migration_test.rb new file mode 100644 index 0000000..0104d37 --- /dev/null +++ b/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 diff --git a/test/rated_test.rb b/test/rated_test.rb new file mode 100644 index 0000000..8a500ab --- /dev/null +++ b/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 + diff --git a/test/schema.rb b/test/schema.rb new file mode 100644 index 0000000..bcc3cf0 --- /dev/null +++ b/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