require 'active_record' require 'active_support' require 'active_support/version' %w{ active_support/core_ext/module/delegation }.each do |active_support_3_requirement| require active_support_3_requirement end if ActiveSupport::VERSION::MAJOR == 3 module ActiveRecord class Relation def inspect_count_only! @_inspect_count_only = true end def inspect_count_only? @_inspect_count_only == true end def as_json(*args) inspect_count_only? ? { :members => count }.as_json : to_a.as_json end def inspect inspect_count_only? ? "" : to_a.inspect end end end module CohortScope def self.extended(base) base.class_eval do cattr_accessor :minimum_cohort_size, :instance_writer => false end end # Find the biggest scope possible by removing constraints in any order. # Returns an empty scope if it can't meet the minimum scope size. def big_cohort(constraints = {}, custom_minimum_cohort_size = nil) raise ArgumentError, "You can't give a big_cohort an OrderedHash; do you want strict_cohort?" if constraints.is_a?(ActiveSupport::OrderedHash) _cohort_scope constraints, custom_minimum_cohort_size end # Find the first acceptable scope by removing constraints in strict order, starting with the last constraint. # Returns an empty scope if it can't meet the minimum scope size. # # constraints must be an ActiveSupport::OrderedHash (no support for ruby 1.9's natively ordered hashes yet). # # Note that the first constraint is implicitly required. # # Take this example, where favorite color is considered to be "more important" than birthdate: # # ordered_constraints = ActiveSupport::OrderedHash.new # ordered_constraints[:favorite_color] = 'heliotrope' # ordered_constraints[:birthdate] = '1999-01-01' # Citizen.strict_cohort(ordered_constraints) #=> [...] # # If the original constraints don't meet the minimum scope size, then the only constraint that can be removed is birthdate. # In other words, this would never return a scope that was constrained on birthdate but not on favorite_color. def strict_cohort(constraints, custom_minimum_cohort_size = nil) raise ArgumentError, "You need to give strict_cohort an OrderedHash" unless constraints.is_a?(ActiveSupport::OrderedHash) _cohort_scope constraints, custom_minimum_cohort_size end protected # Recursively look for a scope that meets the constraints and is at least minimum_cohort_size. def _cohort_scope(constraints, custom_minimum_cohort_size) raise RuntimeError, "You need to set #{name}.minimum_cohort_size = X" unless minimum_cohort_size.present? if constraints.values.none? # failing base case return scoped.where('false') end this_hash = _cohort_constraints constraints this_count = scoped.where(this_hash).count if this_count >= (custom_minimum_cohort_size || minimum_cohort_size) # successful base case cohort = scoped.where this_hash else cohort = _cohort_scope _cohort_reduce_constraints(constraints), custom_minimum_cohort_size end cohort.inspect_count_only! cohort end # Sanitize constraints by # * removing nil constraints (so constraints like "X IS NULL" are impossible, sorry) # * converting ActiveRecord::Base objects into integer foreign key constraints def _cohort_constraints(constraints) new_hash = constraints.is_a?(ActiveSupport::OrderedHash) ? ActiveSupport::OrderedHash.new : Hash.new conditions = constraints.inject(new_hash) do |memo, tuple| k, v = tuple if v.kind_of?(ActiveRecord::Base) condition = { _cohort_association_primary_key(k) => v.to_param } elsif !v.nil? condition = { k => v } end memo.merge! condition if condition.is_a? Hash memo end conditions end # Convert constraints that are provided as ActiveRecord::Base objects into their corresponding integer primary keys. # # Only works for belongs_to relationships. # # For example, :car => <#Car> might get translated into :car_id => 44. def _cohort_association_primary_key(name) @_cohort_association_primary_keys ||= {} return @_cohort_association_primary_keys[name] if @_cohort_association_primary_keys.has_key? name a = reflect_on_association name raise "can't use cohort scope on :through associations (#{self.name} #{name})" if a.options.has_key? :through if !a.primary_key_name.blank? @_cohort_association_primary_keys[name] = a.primary_key_name else raise "we need some other way to find primary key" end end # Choose how to reduce constraints based on whether we're looking for a big cohort or a strict cohort. def _cohort_reduce_constraints(constraints) case constraints when ActiveSupport::OrderedHash _cohort_reduce_constraints_in_order constraints when Hash _cohort_reduce_constraints_seeking_maximum_count constraints else raise "what did you pass me? #{constraints}" end end # (Used by big_cohort) # # Reduce constraints by removing them one by one and counting the results. # # The constraint whose removal leads to the highest record count is removed from the overall constraint set. def _cohort_reduce_constraints_seeking_maximum_count(constraints) highest_count_after_removal = nil losing_key = nil constraints.keys.each do |key| test_constraints = constraints.except(key) count_after_removal = scoped.where(_cohort_constraints(test_constraints)).count if highest_count_after_removal.nil? or count_after_removal > highest_count_after_removal highest_count_after_removal = count_after_removal losing_key = key end end constraints.except losing_key end # (Used by strict_cohort) # # Reduce constraints by removing the least important one. def _cohort_reduce_constraints_in_order(constraints) reduced_constraints = constraints.dup reduced_constraints.delete constraints.keys.last reduced_constraints end end