acts_as_voteable.rb 7.28 KB
# ActsAsVoteable
module Juixe
  module Acts #:nodoc:
    module Voteable #:nodoc:

      def self.included(base)
        base.extend ClassMethods
      end

      module ClassMethods
        #
        # Options:
        #  :vote_counter
        #     Model stores the sum of votes in the  vote counter column when the value is true. This requires a column named `vote_total` in the table corresponding to `voteable` model.
        #     You can also specify a custom vote counter column by providing a column name instead of a true/false value to this option (e.g., :vote_counter => :my_custom_counter.)
        #     Note: Specifying a counter will add it to that model‘s list of readonly attributes using attr_readonly.
        #
        def acts_as_voteable options={}
          has_many :votes, :as => :voteable, :dependent => :destroy
          include Juixe::Acts::Voteable::InstanceMethods
          extend  Juixe::Acts::Voteable::SingletonMethods
          if (options[:vote_counter])
            Vote.send(:include,  Juixe::Acts::Voteable::VoteCounterClassMethods) unless Vote.respond_to?(:vote_counters)
            Vote.vote_counters = [self]
            # define vote_counter_column instance method on voteable
            counter_column_name = (options[:vote_counter] == true) ? :vote_total : options[:vote_counter]
            class_eval <<-EOS
              def self.vote_counter_column           # def self.vote_counter_column
                :"#{counter_column_name}"            #   :vote_total
              end                                    # end
              def vote_counter_column
                self.class.vote_counter_column
              end
            EOS

            define_method(:reload_vote_counter) {reload(:select => vote_counter_column.to_s)}
            attr_readonly counter_column_name
          end
        end
      end

      # This module contains class methods Vote class
      module VoteCounterClassMethods
        def self.included(base)
          base.class_attribute(:vote_counters)
          base.after_create { |record| record.update_vote_counters(1) }
          base.before_destroy { |record| record.update_vote_counters(-1) }
        end

        def update_vote_counters direction
          klass, vtbl = self.voteable.class, self.voteable
          klass.update_counters(vtbl.id, vtbl.vote_counter_column.to_sym => (self.vote * direction) ) if self.vote_counters.any?{|c| c == klass}
        end
      end

      # This module contains class methods
      module SingletonMethods

        # Calculate the vote counts for all voteables of my type.
        # 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. Two calculated columns `count`, and `total`
        #                 are available for sorting apart from other columns. Defaults to `total DESC`.
        #                   Eg: :order => 'count desc'
        #                       :order => 'total desc'
        #                       :order => 'post.created_at desc'
        #  :at_least    - Item must have at least X votes count
        #  :at_most     - Item may not have more than X votes count
        #  :at_least_total    - Item must have at least X votes total
        #  :at_most_total     - Item may not have more than X votes total
        def tally(options = {})
          find(:all, options_for_tally({:order =>"total DESC" }.merge(options)))
        end

        def options_for_tally (options = {})
            options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :at_least_total, :at_most_total

            scope = scope(:find)
            start_at = sanitize_sql(["#{Vote.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
            end_at = sanitize_sql(["#{Vote.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]

            if respond_to?(:vote_counter_column)
              # use the counter cache column if present.
              total_col       = "#{table_name}.#{vote_counter_column}"
              at_least_total  = sanitize_sql(["#{total_col} >= ?", options.delete(:at_least_total)]) if options[:at_least_total]
              at_most_total   = sanitize_sql(["#{total_col} <= ?", options.delete(:at_most_total)])  if options[:at_most_total]
            end
            conditions = [
              options[:conditions],
              at_least_total,
              at_most_total,
              start_at,
              end_at
            ]

            conditions = conditions.compact.join(' AND ')
            conditions = merge_conditions(conditions, scope[:conditions]) if scope

            type_and_context = "#{Vote.table_name}.voteable_type = #{quote_value(base_class.name)}"
            joins = ["LEFT OUTER JOIN #{Vote.table_name} ON #{table_name}.#{primary_key} = #{Vote.table_name}.voteable_id AND #{type_and_context}"]
            joins << scope[:joins] if scope && scope[:joins]
            at_least  = sanitize_sql(["COUNT(#{Vote.table_name}.id) >= ?", options.delete(:at_least)]) if options[:at_least]
            at_most   = sanitize_sql(["COUNT(#{Vote.table_name}.id) <= ?", options.delete(:at_most)]) if options[:at_most]
            at_least_total = at_most_total = nil # reset the values
            unless respond_to?(:vote_counter_column)
              # aggregate the votes when counter cache is absent.
              total_col       = "SUM(#{Vote.table_name}.vote)"
              at_least_total  = sanitize_sql(["#{total_col} >= ?", options.delete(:at_least_total)]) if options[:at_least_total]
              at_most_total   = sanitize_sql(["#{total_col} <= ?", options.delete(:at_most_total)]) if options[:at_most_total]
            end
            having    = [at_least, at_most, at_least_total, at_most_total].compact.join(' AND ')
            group_by  = "#{Vote.table_name}.voteable_id HAVING COUNT(#{Vote.table_name}.id) > 0"
            group_by << " AND #{having}" unless having.blank?

            { :select     => "#{table_name}.*, COUNT(#{Vote.table_name}.id) AS count, #{total_col} AS total",
              :joins      => joins.join(" "),
              :conditions => conditions,
              :group      => group_by
            }.update(options)
        end

      end

      # This module contains instance methods
      module InstanceMethods
        def votes_for
          self.votes.count(:conditions => {:vote => 1})
        end

        def votes_against
          self.votes.count(:conditions => {:vote => -1})
        end

        # Same as voteable.votes.size
        def votes_count
          self.votes.size
        end

        def votes_total
          respond_to?(:vote_counter_column) ? send(self.vote_counter_column) : self.votes.sum(:vote)
        end

        def voters_who_voted
          self.votes.collect(&:voter)
        end

        def voted_by?(voter, for_or_against = "all")
          options = (for_or_against == "all") ? {} : {:vote => (for_or_against ? 1 : -1)}
          self.votes.exists?({:voter_id => voter.id, :voter_type => voter.class.base_class.name}.merge(options))
        end
      end
    end
  end
end