module ReallySimple #:nodoc: module Is #:nodoc: module Popular #:nodoc: def self.append_features(base) super base.extend(ClassMethods) # Returns how many days ago thi instance was created, based on t, a Time object, defaults to Time.now def created_days_ago(t = Time.now) return ((t-(self.created_at))/(60*60*24)) if self.created_at end end #==== Usage: # class Post < ActiveRecord::Base #:nodoc: # is_popular {:votes => {:base => 1, :penalty => 0.019}, # :comments => {:base => 1.3, :penalty => 0.00006}, # :trackbacks => {:base => 1, :penalty => 0.00006}, # :hits => {:base => 0.7, :penalty => 0.00006}}, # {:class_name => "PostPopularity"} # # has_many :hits # has_many :comments # has_many :votes # has_many :trackbacks # end # #This indicates that the Post model's popularity is dependent upon votes, comments, trackbacks, and hits. #Each Vote for a Post is worth 1, with a penalty of .019*n, where n is the how many days ago the vote was created #A score_option is then {:association => {:base => Value of Instance, :penalty => Degrading Penalty}} # #Can be tested via ruby test/unit/is_popular_test.rb module ClassMethods #Add this to a ActiveRecord Model to apply popularity, takes score_options, and has_one options # #score_options is a hash that contains a hash of models and their popularity options # def is_popular(score_options, options = {}) has_one :popularity, :class_name => (options[:class_name] || "#{self.class_name}Popularity"), :dependent => :destroy class_eval { cattr_accessor :is_popular_options } self.is_popular_options = {:score_options => score_options}.merge(options) include InstanceMethods extend SingletonMethods end end module SingletonMethods def find_all_in_chunks(chunk_size = 1000, *args) chunks = [] options = (args.last.is_a?(Hash) ? args.pop : {}) if args ChunkyIterator.new(self, chunk_size, options) end # Finds all instances of model and update's their popularity. def update_popularities(options = {}) find_all_in_chunks(1000, {:select => "id"}).each{|p| p.update_popularity(options)} end #Returns instances of model based on :by options, which can be: # # today (default) # week # month # year # all # #=== Usage # Post.most_popular(:by => "week", :limit => 20) # # Supports eager loading, always including the popularity def most_popular(options = {}) options[:by] ||= "today" options[:limit] ||= 10 includes = (options[:include] ? options[:include].push(:popularity).uniq : [:popularity]) by_field = case options[:by] when "today" "daily_score" when "week" "weekly_score" when "month" "monthly_score" when "year" "yearly_score" when "all" "total_score" end find_options = options.clone.except(:conditions, :by, :order, :include, :limit).merge(:conditions => ["#{(self.is_popular_options[:class_name] || "#{self.class_name.downcase}_popularities").tableize}.id IS NOT NULL"], :order => "#{(self.is_popular_options[:class_name] || "#{self.class_name.downcase}_popularities").tableize}.#{by_field} DESC" , :limit => options[:limit], :include => includes) find(:all, find_options) end end module InstanceMethods #Creates a popularity for an instance def make_popularity(options) create_popularity(:score => options[:new_score]) end #Updates an instance popularity, accepts :now and :new_score options #:now is the Time that the popularity is being updated, defaults to Time.now #:new_score is the score of the popularity, defaults to self.score(t) #where t is :now # #===Usage #@post.update_popularity(:now => (Time.now+2.days), :new_score => (self.score+2)) #Would update @post with an age of 2 days from now and it's score plus an additional 2 # #update_score is defined in the is_popularity model. #That method allows for the time based popularitiyes, such as populady today, or this week. def update_popularity(options = {}) t = options[:now] || Time.now s = options[:new_score] || self.score(t) score = self.popularity ? self.popularity.update_score(:new_score => s, :now => t) : self.make_popularity(:new_score => s) end #Returns the score of the instance, accepts t param which is a Time instance, defaults to Time.now #score is calculated by finding all instances of a popularity dependent association #For each instance, if the model responds to score it will return that value (allows overloading score) #Otherwise, score is dependent upon the following formula: (b-((0.01+(d*n))*n)) # #b is the base value of the popularity #n is the amount of days ago the instance was created from t #d is the penalty or degredation the instance gets based on it's age # #This formula creates a logarithmic decline in value of the instance based on it's age. #A newly created vote for a Post would be worth it's base amount, losing value each day #This allows for old instances to become popular again while favoring newly created content. def score(t = Time.now) scores = {} self.class.is_popular_options[:score_options].each do |model, values| scores[model] = [] self.send(model).each do |o| if o.respond_to?("score") r = o.score else b = values[:base] n = o.created_days_ago(t) <= 1 ? 0 : o.created_days_ago(t) d = values[:penalty] s = (b-((0.01+(d*n))*n)) r = (s > 0 ? s : 0) end scores[model] << r end end return scores.collect{|t,v| v.sum()}.sum() end end class ChunkyIterator include Enumerable def initialize(model_class, chunk_size, options) @model_class = model_class @chunk_size = chunk_size @options = options end def each offset = 0 model_objects = @model_class.find(:all, @options.merge(:offset => offset, :limit => @chunk_size)) until model_objects.empty? while obj = model_objects.shift yield obj end offset += @chunk_size model_objects = @model_class.find(:all, @options.merge(:offset => offset, :limit => @chunk_size)) end end end end end end ActiveRecord::Base.class_eval { include ReallySimple::Is::Popular }