ActiveRecord model for upvotes and downvotes

Posted on

Problem

How can I make my model DRY and KISS?

class Rating < ActiveRecord::Base
  attr_accessible :item_type, :item_id, :rating, :voters_up, :voters_down
  serialize :voters_up, Hash
  serialize :voters_down, Hash
  belongs_to :ranks, :polymorphic => true

  def self.up(item, user, iteration = 1)
    @rating = Rating.where(item_type: item.class.name, item_id: item.id).first
    @rating = Rating.create(item_type: item.class.name, 
                            item_id: item.id, 
                            rating: 0, 
                            voters_up: {users:[]}, voters_down: {users:[]}) unless @rating

    changed = nil
    if !@rating.voters_up[:users].include?(user.id) && !@rating.voters_down[:users].include?(user.id)
      if changed.nil?
        @rating.increment(:rating, 1)
        @rating.voters_up[:users] << user.id
        changed = true
      end
    end

    if @rating.voters_up[:users].include?(user.id) && !@rating.voters_down[:users].include?(user.id)
      if changed.nil?
        @rating.decrement(:rating, 1)
        @rating.voters_up[:users].delete user.id
        changed = true
      end
    end

    if @rating.voters_down[:users].include?(user.id) && !@rating.voters_up[:users].include?(user.id)
      if changed.nil?
        @rating.voters_up[:users] << user.id
        @rating.voters_down[:users].delete user.id
        @rating.increment(:rating, 2)
        changed = true
      end
    end

    @rating.save
    item.update_attribute(:rating_value, @rating.rating)
  end

  def self.down(item, user, iteration = 1)
    @rating = Rating.where(item_type: item.class.name, item_id: item.id).first
    @rating = Rating.create(item_type: item.class.name, 
                            item_id: item.id, 
                            rating: 0, 
                            voters_up: {users:[]}, voters_down: {users:[]}) unless @rating

    changed = nil
    if !@rating.voters_down[:users].include?(user.id) && !@rating.voters_up[:users].include?(user.id)
      if changed.nil?
        @rating.decrement(:rating, 1)
        @rating.voters_down[:users] << user.id
        changed = true
      end
    end

    if @rating.voters_down[:users].include?(user.id) && !@rating.voters_up[:users].include?(user.id)
      if changed.nil?
        @rating.increment(:rating, 1)
        @rating.voters_down[:users].delete user.id
        changed = true
      end
    end

    if @rating.voters_up[:users].include?(user.id) && !@rating.voters_down[:users].include?(user.id)
      if changed.nil?
        @rating.voters_down[:users] << user.id
        @rating.voters_up[:users].delete user.id
        @rating.decrement(:rating, 2)
        changed = true
      end
    end

    @rating.save
    item.update_attribute(:rating_value, @rating.rating)
  end

end

Solution

Here’s a 2-part answer: First, a direct answer to your question, and then an alternative approach to the whole thing.

DRYing the current implementation
Firstly, I’d make sure that every “item” has_one :rating. Right now, you’re doing a lot of manual record creation, and Rating has to leave all of its attributes accessible to mass-assignment. It’d also be nicer to simply say question.upvote or answer.upvote directly, instead of “manually” calling the Rating.up method and having to supply both user and item.

That can be done, provided you can store the current user somewhere that’s accessible to the models. But let’s keep it a little simpler and just go for a syntax like question.upvote(user)

To DRY out the “vote-enabled” models, try this:

# in app/concerns/votable.rb
require 'active_support/concern'

module Votable
  extend ActiveSupport::Concern

  included do
    has_one :rating, :as => :item
  end

  module ClassMethods
    def upvote(user)
      # Build the Rating record if it's missing
      build_rating if rating.nil?
      # Pass the user on
      rating.upvote user
    end

    def downvote(user)
      build_rating if rating.nil?
      rating.downvote user
    end
  end
end

Then, in a record that users can vote on, include that concern:

class Question < ActiveRecord::Base
  include Votable
  # ...
end

class Answer < ActiveRecord::Base
  include Votable
  # ...
end

Now the Question and Answer records will automatically build their own Rating record, if they don’t have one already, and then pass the upvote/downvote calls on to that rating record.

As for Rating itself, here’s what I’d do (I’ve changed the attribute names a little, but you can of course keep using yours):

class Rating < ActiveRecord::Base
  belongs_to :item, :polymorphic => true
  # Serialize stuff as straight-up Array
  serialize :upvoters, Array
  serialize :downvoters, Array
  # Create arrays after record initialization, so they're not nil on new records
  after_initialize :initialize_voters
  # Update the "owner's" rating cache after every save
  after_save :update_item

  # Note: No attr_accessible at all; it's not needed.

  # I'll skip the comments, as this should all be very easily readable.

  def upvote(user)
    if upvoted_by?(user)
      decrement :rating
      remove_upvoter(user)
    elsif downvoted_by?(user)
      increment :rating, 2
      add_upvoter user
      remove_downvoter user
    else
      increment :rating
      add_upvoter user
    end
    save
  end

  def downvote(user)
    if downvoted_by?(user)
      increment :rating
      remove_downvoter user
    elsif upvoted_by?(user)
      decrement :rating, 2
      remove_upvoter user
      add_downvoter user
    else
      decrement :rating
      add_downvoter user
    end
    save
  end

  def upvoted_by?(user)
    upvoters.include? user.id
  end

  def downvoted_by?(user)
    downvoters.include? user.id
  end

  private

    def add_upvoter(user)
      upvoters << user.id
    end

    def add_downvoter(user)
      downvoters << user.id
    end

    def remove_upvoter(user)
      upvoters.delete user.id
    end

    def remove_downvoter(user)
      downvoters.delete user.id
    end

    def initialize_voters
      upvoters ||= []
      downvoters ||= []
    end

    def update_item
      if item.present?
        item.update_attribute :rating_value, rating
      end
    end

end

And that’s pretty much it. Now you can do Question.find(23).upvote(current_user) or whatever, and it should work out.


Going straight to the database
Alternatively, I’d suggest using “raw” database queries instead of models with serialized attributes and all that. All you really need is a simple table. It doesn’t need an id column or a model-layer abstraction. All it needs to is have 4 columns: user_id, vote, item_type, and item_id. You can skip ActiveRecord models and such, since all you need to do is check for a certain user/item combo.

Make user_id, item_id and item_type the primary key (or add a uniqueness constraint) and you can do the entire logic in SQL (at least MySQL). If, for instance, user 23 votes on answer 42, it’d look like this:

INSERT INTO `votes` (`user_id`, `item_id`, `item_type`, `vote`)
VALUES (23, 42, "Answer", X)
ON DUPLICATE KEY UPDATE `vote`=IF(`vote`=X, 0, X);

where X is 1 for an upvote, or -1 for a downvote. That’s actually all the upvote/downvote logic in one query.

Right after that, update the total rating for the post:

UPDATE `answers`
SET `rating_value`=(
  SELECT IFNULL(SUM(`vote`), 0)
  FROM `votes`
  WHERE `item_id`=42 AND `item_type`= "Answer"
)
WHERE id=42;

From time to time, you may want to do some clean-up to keep the table a little leaner (i.e. delete rows where the vote is zero, or when an associated item is deleted), but otherwise you should have a pretty robust solution with none of the overhead of Rails and ActiveRecord. And it can all be abstracted into a mixin like the concern above, and you can easily add the upvoted_by?(user)/downvoted_by?(user) methods there too with some simple SQL:

SELECT * FROM `votes` WHERE `vote`<>0 AND `user_id`=23 AND `item_id`=42 AND `item_type`="Answer";

Leave a Reply

Your email address will not be published. Required fields are marked *