module CachedCounts::ClassMethods
Public Instance Methods
association_count_key(counter_id, attribute_name, version = 1)
click to toggle source
# File lib/cached_counts.rb, line 98 def association_count_key(counter_id, attribute_name, version = 1) "#{name}:#{counter_id}:#{attribute_name}_count:#{version}" unless counter_id.nil? end
caches_count_of(attribute_name, options = {})
click to toggle source
Cache the count for an association in memcached.
e.g.
User.caches_count_of :friends > User.first.friends_count # Users.first.friends.count, but cached
Automatically adds after_commit hooks to the associated class which increment/decrement the value in memcached when needed. Queries the db on cache miss.
Valid options:
:association Name of the association to count. Defaults to the attribute_name (the required argument to `caches_count_of`). :alias Alias(es) for the count attribute. Useful with join tables. e.g. `caches_count_of :user_departments, alias: 'users'` > Department.first.users_count :expires_in Expiry for the cached value. :if proc passed through to the after_commit hooks on the counted class; decides whether an object counts towards the association total. :scope proc used like an ActiveRecord scope on the counted class on cache misses. :version Cache version - bump if you change the definition of a count.
# File lib/cached_counts.rb, line 86 def caches_count_of(attribute_name, options = {}) # Delay actual run to work around circular dependencies klass = self ActiveSupport.on_load :cached_counts do klass.send :caches_count_of!, attribute_name, options end end
caches_count_where(attribute_name, options = {})
click to toggle source
Cache the count for a scope in memcached.
e.g.
User.caches_count_where :confirmed > User.confirmed_count # User.confirmed.count, but cached
Automatically adds after_commit hooks which increment/decrement the value in memcached when needed. Queries the db on cache miss.
Valid options:
:scope Name of the scope to count. Defaults to the attribute_name (the required argument to `caches_count_where`). :alias Alias(es) for the count attribute. e.g. `caches_count_where :confirmed, alias: 'sitemap'` > User.sitemap_count :expires_in Expiry for the cached value. :if proc passed through to the after_commit hooks; decides whether an object counts towards the association total. :version Cache version - bump if you change the definition of a count. :race_condition_fallback Fallback to the result of this proc if the cache is empty, while loading the actual value from the db. Works similarly to race_condition_ttl but for empty caches rather than expired values. Meant to prevent a thundering-herd scenario, if for example a memcached instance goes away. Can be nil; defaults to using a value grabbed from the cache or DB at startup.
# File lib/cached_counts.rb, line 45 def caches_count_where(attribute_name, options = {}) # Delay actual run to work around circular dependencies klass = self ActiveSupport.on_load :cached_counts do klass.send :caches_count_where!, attribute_name, options end end
scope_count_key(attribute_name, version = 1)
click to toggle source
# File lib/cached_counts.rb, line 94 def scope_count_key(attribute_name, version = 1) "#{name}:#{attribute_name}_count:#{version}" end
Protected Instance Methods
add_association_counting_hooks(attribute_name, association, options)
click to toggle source
# File lib/cached_counts.rb, line 204 def add_association_counting_hooks(attribute_name, association, options) key_getter = generate_association_counting_hook_key_getter association, attribute_name, options add_counting_hooks( "#{name.demodulize.underscore}_#{attribute_name}", key_getter, association.klass, options ) end
add_count_attribute_methods(attribute_name, key_getter, relation_getter, define_with, counted_class, options)
click to toggle source
# File lib/cached_counts.rb, line 215 def add_count_attribute_methods(attribute_name, key_getter, relation_getter, define_with, counted_class, options) expires_in = options.fetch :expires_in, 1.week race_condition_fallback = options.fetch :race_condition_fallback, nil key_method = "#{attribute_name}_count_key" send define_with, key_method, &key_getter send define_with, "#{attribute_name}_count" do val = Rails.cache.fetch( send(key_method), expires_in: expires_in, race_condition_ttl: 30.seconds, raw: true # Necessary for incrementing to work correctly ) do if race_condition_fallback # Ensure that other reads find something in the cache, but # continue calculating here because the default is likely inaccurate. fallback_value = instance_exec &race_condition_fallback Rails.cache.write( send(key_method), fallback_value.to_i, expires_in: 30.seconds, raw: true ) end relation = instance_exec(&relation_getter) relation = relation.reorder('') relation.select_values = ['count(*)'] conn = CachedCounts.connection_for(counted_class) if Rails.version < '4.2'.freeze conn.select_value(relation.to_sql, nil, relation.values[:bind] || []).to_i else conn.select_value(relation.to_sql).to_i end end if val.is_a?(ActiveSupport::Cache::Entry) val.value.to_i else val.to_i end end send define_with, "#{attribute_name}_count=" do |value| Rails.cache.write( send(key_method), value.to_i, expires_in: expires_in, raw: true ) end send define_with, "expire_#{attribute_name}_count" do Rails.cache.delete send(key_method) end end
add_counting_hooks(attribute_name, key_getter, counted_class, options)
click to toggle source
# File lib/cached_counts.rb, line 275 def add_counting_hooks(attribute_name, key_getter, counted_class, options) increment_hook = "increment_#{attribute_name}_count" counted_class.send :define_method, increment_hook do if (key = instance_exec &key_getter) Rails.cache.increment( key, 1, initial: nil # Increment only if the key already exists ) end end decrement_hook = "decrement_#{attribute_name}_count" counted_class.send :define_method, decrement_hook do if (key = instance_exec &key_getter) Rails.cache.decrement( key, 1, initial: nil # Decrement only if the key already exists ) end end counted_class.after_commit increment_hook, options.slice(:if).merge(on: :create) # This is ridiculous, but I can't find a better way to test for it need_without_protection = instance_method(:assign_attributes).arity != 1 if (if_proc = options[:if]) if if_proc.is_a?(Symbol) if_proc = ->{ send(options[:if]) } end recorded_eligibility_var = "@_was_eligible_for_#{attribute_name}_count" counted_class.before_destroy do instance_variable_set recorded_eligibility_var, !!instance_exec(&if_proc) true end counted_class.after_commit on: :destroy do if instance_variable_get(recorded_eligibility_var) send(decrement_hook) end end counted_class.after_commit on: :update do # There is no before-hook which will reliably have access to the # previous version of the object, so we need to simulate it. previous_values = previous_changes.each_with_object({}) do |(key,vals), memo| memo[key] = vals.first end old_version = dup if need_without_protection old_version.assign_attributes previous_values, without_protection: true else old_version.assign_attributes previous_values end was = !!old_version.instance_exec(&if_proc) is = !!instance_exec(&if_proc) if was != is if is send(increment_hook) else send(decrement_hook) end end end else counted_class.after_commit decrement_hook, on: :destroy end end
add_scope_counting_hooks(attribute_name, options)
click to toggle source
# File lib/cached_counts.rb, line 192 def add_scope_counting_hooks(attribute_name, options) version = options.fetch :version, 1 key = scope_count_key(attribute_name, version) add_counting_hooks( attribute_name, -> { key }, self, options ) end
caches_count_of!(attribute_name, options)
click to toggle source
# File lib/cached_counts.rb, line 113 def caches_count_of!(attribute_name, options) association_name = options.fetch :association, attribute_name association = reflect_on_association(association_name.to_sym) raise "#{self} does not have an association named #{association_name}" unless association define_association_count_attribute attribute_name, association, options add_association_counting_hooks attribute_name, association, options end
caches_count_where!(attribute_name, options)
click to toggle source
# File lib/cached_counts.rb, line 104 def caches_count_where!(attribute_name, options) scope_name = options.fetch :scope, attribute_name relation = send(scope_name) if respond_to?(scope_name) raise "#{self} does not have a scope named #{scope_name}" unless relation.is_a?(ActiveRecord::Relation) define_scope_count_attribute attribute_name, relation, options add_scope_counting_hooks attribute_name, options end
default_race_condition_fallback_proc(key, relation, options)
click to toggle source
# File lib/cached_counts.rb, line 148 def default_race_condition_fallback_proc(key, relation, options) fallback = Rails.cache.read(key) fallback = fallback.value if fallback.is_a?(ActiveSupport::Cache::Entry) if fallback.nil? begin fallback = relation.count rescue ActiveRecord::StatementInvalid => e fallback = 0 end Rails.cache.write key, fallback, expires_in: options.fetch(:expires_in, 1.week) end -> { fallback } end
define_association_count_attribute(attribute_name, association, options)
click to toggle source
# File lib/cached_counts.rb, line 165 def define_association_count_attribute(attribute_name, association, options) options = options.dup version = options.fetch :version, 1 key_getter = -> { self.class.association_count_key(id, attribute_name, version) } relation_getter = generate_association_relation_getter(association, options) [attribute_name, *Array(options[:alias])].each do |attr_name| define_singleton_method "#{attr_name}_count_key" do |id| association_count_key(id, attribute_name, version) end define_singleton_method "#{attr_name}_count_for" do |id| new({id: id}, without_protection: true).send("#{attr_name}_count") end add_count_attribute_methods( attr_name, key_getter, relation_getter, :define_method, association.klass, options ) end end
define_scope_count_attribute(attribute_name, relation, options)
click to toggle source
# File lib/cached_counts.rb, line 122 def define_scope_count_attribute(attribute_name, relation, options) options = options.dup version = options.fetch :version, 1 key = scope_count_key(attribute_name, version) unless options.has_key?(:race_condition_fallback) options[:race_condition_fallback] = default_race_condition_fallback_proc( key, relation, options ) end [attribute_name, *Array(options[:alias])].each do |attr_name| add_count_attribute_methods( attr_name, -> { key }, -> { relation }, :define_singleton_method, self, options ) end end
generate_association_counting_hook_key_getter(association, attribute_name, options)
click to toggle source
# File lib/cached_counts.rb, line 348 def generate_association_counting_hook_key_getter(association, attribute_name, options) version = options.fetch :version, 1 counting_class = self if association.through_reflection method_chain = association.chain.map do |association| if (source = association.source_reflection) raise "Chained associations without `inverse_of` are not supported!" unless source.inverse_of source.inverse_of.name else association.foreign_key end end proc do counter_id = method_chain.inject(self) do |memo, method| memo.send(method) unless memo.nil? end counter_id = counter_id.id if counter_id.is_a?(ActiveRecord::Base) counting_class.association_count_key counter_id, attribute_name, version end else foreign_key = association.foreign_key proc do counting_class.association_count_key send(foreign_key), attribute_name, version end end end
generate_association_relation_getter(association, options)
click to toggle source
# File lib/cached_counts.rb, line 379 def generate_association_relation_getter(association, options) counted_class = association.klass association_name = association.name if (scope_proc = options[:scope]) -> { send(association_name).spawn.scoping { counted_class.instance_exec(&scope_proc) } } else -> { send(association_name).spawn } end end