Ballot by Kinetic Cafe

code

github.com/KineticCafe/ruby-ballot/

issues

github.com/KineticCafe/ruby-ballot/issues

docs

www.rubydoc.info/github/KineticCafe/ruby-ballot/master

continuous integration

Build Status

Description

Ballot provides a two-way polymorphic scoped voting mechanism for both ActiveRecord (4 or later) and Sequel (4 or later).

Overview

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

Synopsis

The API for Ballot is consistent for both ActiveRecord and Sequel.

ActiveRecord

class Post < ActiveRecord::Base
  acts_as_ballot :votable # or acts_as_ballot_votable
end

class User < ActiveRecord::Base
  acts_as_ballot :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

Sequel

class Post < Sequel::Model
  plugin :ballot_votable
  # or acts_as_ballot :votable or acts_as_ballot_votable
end

class User < Sequel::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

Ballot Words

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)

Scoped Votes

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

Weighted Votes

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:

  1. Ballot has an orthogonal API between Votable and Voter objects. Votable objects receive #ballot_by to cast a vote, Voter objects receive #cast_ballot_for to cast a vote (or #ballot_for). None of the aliases added by acts_as_votable exist in Ballot.

  2. Votable objects are associated on #ballots_for (themselves) and ask whether a ballot was cast *_by Voter objects. Voter objects are associated on #ballots_by (themselves) and ask whether a ballot was cast *_for Votable objects.

  3. Validation is performed on the votables or voters passed to vote methods, ensuring that the object is a Votable or a Voter.

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

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

Planned Improvements

  • Batch voting.

Install

Add Ballot to your Gemfile:

gem 'ballot', '~> 1.0'

Or manually install:

% gem install ballot

Supported Versions

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

Database Migrations

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.

Community and Contributing

Ballot welcomes your contributions as described in Contributing.md. This project, like all Kinetic Cafe open source projects, is under the Kinetic Cafe Open Source Code of Conduct.