Permalink
Browse files

Version bump to 0.5.0, with #tally speedups!

  • Loading branch information...
1 parent 9ab5c5d commit 2ad8cef8e2221f429a43ea8d37eee78d2dfba433 @bouchard committed Feb 17, 2012
File renamed without changes.
View
14 Gemfile
@@ -1,13 +1 @@
-source :rubygems
-
-gem 'activerecord'
-
-group :development do
- gem 'bundler'
- gem 'jeweler'
- gem 'simplecov'
-end
-
-group :test do
- gem 'sqlite3'
-end
+gemspec
View
@@ -1,4 +1,4 @@
-Copyright (c) 2010 Brady Bouchard (ldawn.com)
+Copyright (c) 2011 Brady Bouchard (thewellinspired.com)
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
@@ -1,6 +1,8 @@
ThumbsUp
=======
+**Note: Version 0.5.x is a breaking change for #plusminus_tally and #tally, with > 50% speedups.**
+
A ridiculously straightforward and simple package 'o' code to enable voting in your application, a la stackoverflow.com, etc.
Allows an arbitrary number of entities (users, etc.) to vote on models.
@@ -88,51 +90,17 @@ Did the first user vote for or against the Car with id = 2?
You can easily retrieve voteable object collections based on the properties of their votes:
- @items = Item.tally(
- { :at_least => 1,
- :at_most => 10000,
- :start_at => 2.weeks.ago,
- :end_at => 1.day.ago,
- :limit => 10,
- :order => "items.name DESC"
- })
-
-This will select the Items with between 1 and 10,000 votes, the votes having been cast within the last two weeks (not including today), then display the 10 last items in an alphabetical list. *This tallies all votes, regardless of whether they are +1 (up) or -1 (down).*
+ @items = Item.tally.limit(10).where('created_at > ?', 2.days.ago).having('vote_count < 10')
-
-##### Tally Options:
- :start_at - Restrict the votes to those created after a certain time
- :end_at - Restrict the votes to those created before a certain time
- :conditions - A piece of SQL conditions to add to the query
- :limit - The maximum number of voteables to return
- :order - A piece of SQL to order by. Eg 'votes.count desc' or 'voteable.created_at desc'
- :at_least - Item must have at least X votes
- :at_most - Item may not have more than X votes
+This will select the Items with less than 10 votes, the votes having been cast within the last two days, with a limit of 10 items. *This tallies all votes, regardless of whether they are +1 (up) or -1 (down).* The #tally method returns an ActiveRecord Relation, so you can chain the normal method calls on to it.
#### Tallying Rank ("Plusminus")
**You most likely want to use this over the normal tally**
-This is similar to tallying votes, but this will return voteable object collections based on the sum of the differences between up and down votes (ups are +1, downs are -1). For Instance, a voteable with 3 upvotes and 2
-downvotes will have a plusminus of 1.
-
- @items = Item.plusminus_tally(
- { :at_least => 1,
- :at_most => 10000,
- :start_at => 2.weeks.ago,
- :end_at => 1.day.ago,
- :limit => 10,
- :order => "items.name DESC"
- })
-
-##### Plusminus Tally Options:
- :start_at - Restrict the votes to those created after a certain time
- :end_at - Restrict the votes to those created before a certain time
- :conditions - A piece of SQL conditions to add to the query
- :limit - The maximum number of voteables to return
- :ascending - Boolean Default false. If specified true, results will be returned in ascending order (from bottom up)
- :at_least - Item must have at least X votes
- :at_most - Item may not have more than X votes
+This is similar to tallying votes, but this will return voteable object collections based on the sum of the differences between up and down votes (ups are +1, downs are -1). For Instance, a voteable with 3 upvotes and 2 downvotes will have a plusminus of 1.
+
+ @items = Item.plusminus_tally.limit(10).where('created_at > ?', 2.days.ago).having('plusminus > 10')
#### Lower level queries
@@ -163,6 +131,19 @@ You can also use `--unique-voting false` when running the generator command:
rails generate thumbs_up --unique-voting false
+#### Testing ThumbsUp
+
+Testing is a bit more than trivial now as our #tally and #plusminus_tally queries don't function properly under SQLite. To set up for testing:
+
+```
+$ mysql -uroot # You may have set a password locally. Change as needed.
+ > GRANT ALL PRIVILEGES ON 'thumbs_up_test' to 'test'@'localhost' IDENTIFIED BY 'test';
+ > CREATE DATABASE 'thumbs_up_test';
+ > exit;
+
+$ rake # Runs the test suite.
+```
+
Credits
=======
View
@@ -1,6 +1,9 @@
# encoding: UTF-8
require 'rubygems'
-require 'bundler'
+require 'bundler' unless defined?(Bundler)
+
+$LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
+require 'thumbs_up/version'
begin
Bundler.setup(:default, :development)
@@ -11,32 +14,19 @@ rescue Bundler::BundlerError => e
end
require 'rake'
-require 'jeweler'
-Jeweler::Tasks.new do |gem|
- gem.name = "thumbs_up"
- gem.summary = "Voting for ActiveRecord with multiple vote sources and karma calculation."
- gem.description = "ThumbsUp provides dead-simple voting capabilities to ActiveRecord models with karma calculation, a la stackoverflow.com."
- gem.email = "brady@ldawn.com"
- gem.homepage = "http://github.com/brady8/thumbs_up"
- gem.authors = ["Brady Bouchard", "Peter Jackson", "Cosmin Radoi", "Bence Nagy", "Rob Maddox", "Wojciech Wnętrzak"]
- # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
-end
-Jeweler::RubygemsDotOrgTasks.new
-
require 'rake/testtask'
Rake::TestTask.new(:test) do |test|
test.libs << 'lib' << 'test'
test.test_files = Dir.glob("test/**/*_test.rb")
test.verbose = true
end
-require 'rdoc/task'
-Rake::RDocTask.new do |rdoc|
- version = File.exist?('VERSION') ? File.read('VERSION') : ""
- rdoc.rdoc_dir = 'rdoc'
- rdoc.title = "leaderboard #{version}"
- rdoc.rdoc_files.include('README*')
- rdoc.rdoc_files.include('lib/**/*.rb')
+task :build do
+ system "gem build thumbs_up.gemspec"
+end
+
+task :release => :build do
+ system "gem push thumbs_up-#{ThumbsUp::VERSION}"
end
task :default => :test
View
@@ -1 +0,0 @@
-0.4.6
View
@@ -16,108 +16,35 @@ def acts_as_voteable
module SingletonMethods
- # The point of this function is to return rankings based on the difference between up and down votes
- # assuming equal weighting (i.e. a user with 1 up vote and 1 down vote has a Vote_Total of 0.
- # First the votes table is joined twiced so that the Vote_Total can be calculated for every ID
- # Then this table is joined against the specific table passed to this function to allow for
- # ranking of the items within that table based on the difference between up and down votes.
- # Options:
- # :start_at - Restrict the votes to those created after a certain time
- # :end_at - Restrict the votes to those created before a certain time
- # :ascending - Default false - normal order DESC (i.e. highest rank to lowest)
- # :at_least - Default 1 - Item must have at least X votes
- # :at_most - Item may not have more than X votes
- # :conditions - (string) Extra conditions, if you'd like.
- def plusminus_tally(*args)
- options = args.extract_options!
-
- tsub0 = Vote
- tsub0 = tsub0.where("vote = ?", false)
- tsub0 = tsub0.where("voteable_type = ?", self.name)
- tsub0 = tsub0.group("voteable_id")
- tsub0 = tsub0.select("DISTINCT voteable_id, COUNT(vote) as votes_against")
-
- tsub1 = Vote
- tsub1 = tsub1.where("vote = ?", true)
- tsub1 = tsub1.where("voteable_type = ?", self.name)
- tsub1 = tsub1.group("voteable_id")
- tsub1 = tsub1.select("DISTINCT voteable_id, COUNT(vote) as votes_for")
-
- t = self.joins("LEFT OUTER JOIN (SELECT DISTINCT #{Vote.table_name}.*,
- (COALESCE(vfor.votes_for, 0)-COALESCE(against.votes_against, 0)) AS vote_total
- FROM (#{Vote.table_name} LEFT JOIN
- (#{tsub0.to_sql}) AS against ON #{Vote.table_name}.voteable_id = against.voteable_id)
- LEFT JOIN
- (#{tsub1.to_sql}) as vfor ON #{Vote.table_name}.voteable_id = vfor.voteable_id)
- AS joined_#{Vote.table_name} ON #{self.table_name}.#{self.primary_key} =
- joined_#{Vote.table_name}.voteable_id")
-
- t = t.group("joined_#{Vote.table_name}.voteable_id, joined_#{Vote.table_name}.vote_total, #{column_names_for_tally}")
- t = t.limit(options[:limit]) if options[:limit]
- t = t.where("joined_#{Vote.table_name}.voteable_type = '#{self.name}'")
- t = t.where("joined_#{Vote.table_name}.created_at >= ?", options[:start_at]) if options[:start_at]
- t = t.where("joined_#{Vote.table_name}.created_at <= ?", options[:end_at]) if options[:end_at]
- t = t.where(options[:conditions]) if options[:conditions]
- t = options[:ascending] ? t.order("joined_#{Vote.table_name}.vote_total") : t.order("joined_#{Vote.table_name}.vote_total DESC")
-
- t = t.having([
- "COUNT(joined_#{Vote.table_name}.voteable_id) > 0",
- (options[:at_least] ?
- "joined_#{Vote.table_name}.vote_total >= #{sanitize(options[:at_least])}" : nil
- ),
- (options[:at_most] ?
- "joined_#{Vote.table_name}.vote_total <= #{sanitize(options[:at_most])}" : nil
- )
- ].compact.join(' AND '))
-
- t.select("#{self.table_name}.*, joined_#{Vote.table_name}.vote_total")
+ # Calculate the plusminus for a group of voteables in one database query.
+ # This returns an Arel relation, so you can add conditions as you like chained on to
+ # this method call.
+ # i.e. Posts.tally.where('votes.created_at > ?', 2.days.ago)
+ def plusminus_tally
+ t = self.joins("LEFT OUTER JOIN #{Vote.table_name} ON #{self.table_name}.id = #{Vote.table_name}.voteable_id")
+ t = t.order("plusminus DESC")
+ t = t.group("#{self.table_name}.id")
+ t = t.select("#{self.table_name}.*")
+ t = t.select("SUM(CASE CAST(#{Vote.table_name}.vote AS UNSIGNED) WHEN 1 THEN 1 WHEN 0 THEN -1 ELSE 0 END) AS plusminus")
@kevinelliott

kevinelliott Feb 23, 2012

For Postgres, using "int4(#{Vote.table_name}.vote)" would fix the UNSIGNED bug since UNSIGNED is not a valid type in Postgres. This allows for values up to 2 billion. If the value is always 1, 0 or -1 then integer() is a valid call.

I'm now looking at ActiveRecord to see if there's something that can be used that is not database specific to abstract these casts.

@kevinelliott

kevinelliott Feb 23, 2012

This StackOverflow answer recommends casting before the data is passed in (http://stackoverflow.com/questions/1085280/typecasting-a-custom-column-in-rails-activerecord)... Is there a reason the cast is occurring? Is the cast artificially changing a -1 vote value to 0? Or is there no real circumstance where the value needs to be cast?

@bouchard

bouchard Feb 23, 2012

Owner

Good question. You can test for yourself, but I found I needed the cast otherwise the arithmetic worked out weird... it would end up with a signed integer overflow for some reason. To be honest, it was a quick fix and I didn't see any harm in it, so I never got to the root of the issue.

@kevinelliott

kevinelliott Feb 23, 2012

Since votes.vote is a boolean, have you tried simply matching against true/false in your CASE instead of forcing the cast? Seems to work for me over here.

@bouchard

bouchard via email Feb 23, 2012

Owner
@kevinelliott

kevinelliott Feb 23, 2012

Cool. In any case I submitted a DB independent pull request if you want to keep the casting in place. :)

+ t = t.select("COUNT(#{Vote.table_name}.id) AS vote_count")
end
-
+
# #rank_tally is depreciated.
alias_method :rank_tally, :plusminus_tally
# Calculate the vote counts for all voteables of my type.
- # This method returns all voteables with at least one vote.
+ # This method returns all voteables (even without any votes) by default.
# The vote count for each voteable is available as #vote_count.
- #
- # Options:
- # :start_at - Restrict the votes to those created after a certain time
- # :end_at - Restrict the votes to those created before a certain time
- # :conditions - A piece of SQL conditions to add to the query
- # :limit - The maximum number of voteables to return
- # :order - A piece of SQL to order by. Eg 'vote_count DESC' or 'voteable.created_at DESC'
- # :at_least - Item must have at least X votes
- # :at_most - Item may not have more than X votes
+ # This returns an Arel relation, so you can add conditions as you like chained on to
+ # this method call.
+ # i.e. Posts.tally.where('votes.created_at > ?', 2.days.ago)
def tally(*args)
- options = args.extract_options!
-
- # Use the explicit SQL statement throughout for Postgresql compatibility.
- vote_count = "COUNT(#{Vote.table_name}.voteable_id)"
-
- t = self.where("#{Vote.table_name}.voteable_type = '#{self.name}'")
-
- # We join so that you can order by columns on the voteable model.
- t = t.joins("LEFT OUTER JOIN #{Vote.table_name} ON #{self.table_name}.#{self.primary_key} = #{Vote.table_name}.voteable_id")
-
- t = t.group("#{Vote.table_name}.voteable_id, #{column_names_for_tally}")
- t = t.limit(options[:limit]) if options[:limit]
- t = t.where("#{Vote.table_name}.created_at >= ?", options[:start_at]) if options[:start_at]
- t = t.where("#{Vote.table_name}.created_at <= ?", options[:end_at]) if options[:end_at]
- t = t.where(options[:conditions]) if options[:conditions]
- t = options[:order] ? t.order(options[:order]) : t.order("#{vote_count} DESC")
-
- # I haven't been able to confirm this bug yet, but Arel (2.0.7) currently blows up
- # with multiple 'having' clauses. So we hack them all into one for now.
- # If you have a more elegant solution, a pull request on Github would be greatly appreciated.
- t = t.having([
- "#{vote_count} > 0",
- (options[:at_least] ? "#{vote_count} >= #{sanitize(options[:at_least])}" : nil),
- (options[:at_most] ? "#{vote_count} <= #{sanitize(options[:at_most])}" : nil)
- ].compact.join(' AND '))
- # t = t.having("#{vote_count} > 0")
- # t = t.having(["#{vote_count} >= ?", options[:at_least]]) if options[:at_least]
- # t = t.having(["#{vote_count} <= ?", options[:at_most]]) if options[:at_most]
- t.select("#{self.table_name}.*, COUNT(#{Vote.table_name}.voteable_id) AS vote_count")
+ t = self.joins("LEFT OUTER JOIN #{Vote.table_name} ON #{self.table_name}.id = #{Vote.table_name}.voteable_id")
+ t = t.order("vote_count DESC")
+ t = t.group("#{self.table_name}.id")
+ t = t.select("#{self.table_name}.*")
+ t = t.select("#{Vote.table_name}.*")
+ t = t.select("COUNT(#{Vote.table_name}.id) AS vote_count")
end
def column_names_for_tally
@@ -129,11 +56,11 @@ def column_names_for_tally
module InstanceMethods
def votes_for
- Vote.where(:voteable_id => id, :voteable_type => self.class.name, :vote => true).count
+ self.votes.where(:vote => true).count
end
def votes_against
- Vote.where(:voteable_id => id, :voteable_type => self.class.name, :vote => false).count
+ self.votes.where(:vote => false).count
end
def percent_for
@@ -162,7 +89,6 @@ def voted_by?(voter)
0 < Vote.where(
:voteable_id => self.id,
:voteable_type => self.class.name,
- :voter_type => voter.class.name,
:voter_id => voter.id
).count
end
View
@@ -0,0 +1,3 @@
+module ThumbsUp
+ VERSION = '0.5.0'
+end
View
@@ -7,10 +7,18 @@
require 'active_record'
-ActiveRecord::Base.establish_connection(
- :adapter => "sqlite3",
- :database => ":memory:"
-)
+config = {
+ :adapter => 'mysql2',
+ :database => 'thumbs_up_test',
+ :username => 'test',
+ :password => 'test',
+ :socket => '/tmp/mysql.sock'
+}
+
+ActiveRecord::Base.establish_connection(config)
+ActiveRecord::Base.connection.drop_database config[:database] rescue nil
+ActiveRecord::Base.connection.create_database config[:database]
+ActiveRecord::Base.establish_connection(config)
ActiveRecord::Migration.verbose = false
Oops, something went wrong.

5 comments on commit 2ad8cef

This commit breaks plusminus_tally and does not keep the functionality of specifying at_least. No reasonable replacement seems to exist.

UPDATE: Chaining on having('plusminus > 0') seems to be a replacement for :at_least => 1.

Owner

bouchard replied Feb 23, 2012

Hello Kevin,

The README was updated to specify as much. I updated those methods to get Arel chainability and major speed improvements over the old version. :at_least, and every other option you can get by chaining Arel / ActiveRecord methods: Item.plusminus_tally.having('vote_count > 4') for example.

An error is thrown when using Postgres with plusminus_tally:

PG::Error: ERROR: type "unsigned" does not exist

Owner

bouchard replied Feb 23, 2012

Patches to support Postgres are welcome. I use MySQL exclusively.

Thanks Brady. I'll look into it.

Please sign in to comment.