Ballot by Kinetic Cafe
- continuous integration
Ballot provides a two-way polymorphic scoped voting mechanism for both ActiveRecord (4 or later) and Sequel (4 or later).
Two-way polymorphic: any model can be a voter or a votable.
Scoped: multiple votes can be recorded for a votable, under different scopes.
Ballot started as an opinionated port of acts_as_votable to Sequel. As the port formed, we made aggressive changes to both the data models and API which we wanted to share between our various applications, whether they used ActiveRecord or Sequel. The design decisions made here may not suit your needs, and we heartily recommend acts_as_votable if they do not.
Ballot has been written to be able to coexist with acts_as_votable.
Ballot does not provide a direct migration from acts_as_votable; it uses a
different table (
ballot_votes), so can coexist with
The API for Ballot is consistent for both ActiveRecord and Sequel.
class Post < ActiveRecord::Base :votable # or acts_as_ballot_votable end class User < ActiveRecord::Base :voter # or acts_as_ballot_voter end post = Post.create(name: 'My amazing post!') current_user.cast_ballot_for post # An up-vote by current_user! post.ballots_for.count # => 1 current_user.ballots_by.count # => 1 current_user.remove_ballot_for post # Remove the vote! :( post.ballots_for.any? # => false current_user.ballots_by.none? # => true
class Post < ::Model plugin :ballot_votable # or acts_as_ballot :votable or acts_as_ballot_votable end class User < ::Model plugin :ballot_voter # or acts_as_ballot :voter or acts_as_ballot_voter end post = Post.create(title: 'My amazing post!') current_user.cast_ballot_for post # An up-vote by current_user! post.ballots_for_dataset.count # => 1 current_user.ballots_by_dataset.count # => 1 current_user.remove_ballot_for post # Remove the vote! :( post.ballots_for_dataset.any? # => false current_user.ballots_by_dataset.none? # => true
Exploring the API
Unless otherwise specified, votes are positive. This can be specified by using the `vote` parameter, which will be parsed through Ballot::Words#truthy? for interpretation.
# All of these mean the same thing post.ballot_by current_user, vote: 'bad' post.ballot_by current_user, vote: '0' post.ballot_by current_user, vote: 'false' post.ballot_by current_user, vote: false post.ballot_by current_user, vote: -1 post.down_ballot_by current_user current_user.cast_down_ballot_for(post)
Scopes provide purpose or reasons for votes. These are isolated vote collections. One could emulate Facebook reactions with scopes:
current_user.ballot_for post # default, unspecified scope current_user.ballot_for post, scope: 'love' # 'love' scope current_user.ballot_for post, scope: 'haha' # 'haha' scope current_user.ballot_for post, scope: 'wow' # 'wow' scope current_user.ballot_for post, scope: 'sad' # 'sad' scope current_user.ballot_for post, scope: 'angry' # 'angry' scope
Ballot does not provide uniqueness across scopes so that a voter can only have one reaction to a votable.
Queries are segregated by scopes as well:
current_user.cast_ballot_for? post # default, unspecified scope current_user.cast_ballot_for? post, scope: 'love' # 'love' scope
Votes may be weighted so that some votes count more than others (the default weight is 1). This affects the score of ballots, which is a distinct concept from the count of ballots.
current_user.cast_ballot_for post, weight: 2 post.total_ballots # => 1 post.ballot_score # => 2
Registered Votes and Duplicate Votes
By default, voters can only vote a particular once per model in a given vote scope.
current_user.cast_ballot_for post current_user.cast_ballot_for post post.total_ballots # => 1
A votable can be checked after voting to see if the vote counted; this is true only when a vote has been created or changed.
current_user.cast_ballot_for post post.vote_registered? # => true current_user.cast_ballot_for post post.vote_registered? # => false current_user.cast_down_ballot_for post post.vote_registered? # => true post.total_ballots # => 1
Duplicate votes may be permitted through the use of the keyword argument
duplicate when casting the vote:
current_user.cast_ballot_for post post.vote_registered? # => true current_user.cast_ballot_for post, duplicate: true post.vote_registered? # => true current_user.cast_down_ballot_for post, duplicate: true post.vote_registered? # => true post.total_ballots # => 3
Not all methods properly handle duplicate votes (as the
post.total_ballots line demonstrates), and it has a negative
impact on performance at a large enough scale. Its use is discouraged.
Cached Ballot Summary
Performance for some common queries can be sped up by adding a JSON field
to a Votable model,
cached_ballot_summary. This is updated
after each vote on a votable. When added, this caches the results for
all vote scopes.
user1.cast_ballot_for post, weight: 4 user2.cast_ballot_for post, vote: false post.total_ballots # => 2 post.total_up_ballots # => 1 post.total_down_ballots # => 1 post.ballot_score # => 0 post.weighted_ballot_total # => 5 post.weighted_ballot_score # => 3 post.weighted_ballot_average # => 1.5
API Differences with acts_as_votable
There are a number of API differences between acts_as_votable and Ballot:
Ballot has an orthogonal API between Votable and Voter objects. Votable objects receive
#ballot_byto cast a vote, Voter objects receive
#cast_ballot_forto cast a vote (or
#ballot_for). None of the aliases added by acts_as_votable exist in Ballot.
Votable objects are associated on
#ballots_for(themselves) and ask whether a ballot was cast
*_byVoter objects. Voter objects are associated on
#ballots_by(themselves) and ask whether a ballot was cast
Validation is performed on the votables or voters passed to vote methods, ensuring that the object is a Votable or a Voter.
Votables can cache summary data about votes made for the votable, enabled with a single JSON column per votable,
cached_ballot_summary. It implicitly provides caching for all scopes.
Vote scopes are completely isolated, even on queries. The unspecified (default) vote scope is independent of named vote scopes. Under acts_as_votable, you could ask
votable.voted_on_by?(voter)and the answer would be provided without regard to the vote scope. This is not supported by Ballot, where the same question (
votable.ballot_by?(voter)) is explicitly in the unspecified vote scope. If this behaviour is required, it is easy enough to ask using query methods provided by ActiveRecord or Sequel:
# ActiveRecord votable.ballots_for(voter_id: voter.id, voter_type: voter.type).any? # Sequel votable.ballots_for_dataset(voter_id: voter.id, voter_type: voter.type).any?
Add Ballot to your Gemfile:
gem 'ballot', '~> 1.0'
Or manually install:
% gem install ballot
Ballot is written using Ruby 2 syntax and supports Active Record 4, Active Record 5, and Sequel 4.
Ballot is tested with these combinations of Ruby interpreters and ORMs:
Ruby 2.0, 2.1; ActiveRecord 4; Sequel 4
Ruby 2.2; ActiveRecord 4, 5; Sequel 4
JRuby 9.0, 9.1; ActiveRecord 4; Sequel 4
Ballot uses a table (
ballot_votes) to store all votes. When
using Rails, you can generate the migration as normal:
rails generate ballot:install rake db:migrate
Performance can be increased by adding the
cached_ballot_summary column to your votable tables. This can
be added with a different migration:
rails generate ballot:summary VOTABLE
When not using Rails, you can use the ballot_generator binary.
ballot_generator [--orm ORM] --install ballot_generator [--orm ORM] --summary NAME
Ballot Semantic Versioning
Ballot uses a Semantic Versioning scheme with one significant change:
When PATCH is zero (
0), it will be omitted from version references.