Module: CounterCulture::ActiveRecord::ClassMethods

Defined in:
lib/counter_culture.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#after_commit_counter_cacheObject (readonly)

this holds all configuration data



14
15
16
# File 'lib/counter_culture.rb', line 14

def after_commit_counter_cache
  @after_commit_counter_cache
end

Instance Method Details

#counter_culture(relation, options = {}) ⇒ Object

called to configure counter caches



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/counter_culture.rb', line 17

def counter_culture(relation, options = {})
  unless @after_commit_counter_cache
    # initialize callbacks only once
    after_create :_update_counts_after_create
    after_destroy :_update_counts_after_destroy
    after_update :_update_counts_after_update

    # we keep a list of all counter caches we must maintain
    @after_commit_counter_cache = []
  end

  # add the current information to our list
  @after_commit_counter_cache<< {
    :relation => relation.is_a?(Enumerable) ? relation : [relation],
    :counter_cache_name => (options[:column_name] || "#{name.tableize}_count"),
    :column_names => options[:column_names],
    :delta_column => options[:delta_column],
    :foreign_key_values => options[:foreign_key_values],
    :touch => options[:touch]
  }
end

#counter_culture_fix_counts(options = {}) ⇒ Object

checks all of the declared counter caches on this class for correctnes based on original data; if the counter cache is incorrect, sets it to the correct count

options:

{ :exclude => list of relations to skip when fixing counts,
  :only => only these relations will have their counts fixed }

returns: a list of fixed record as an array of hashes of the form:

{ :entity => which model the count was fixed on,
  :id => the id of the model that had the incorrect count,
  :what => which column contained the incorrect count,
  :wrong => the previously saved, incorrect count,
  :right => the newly fixed, correct count }


53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/counter_culture.rb', line 53

def counter_culture_fix_counts(options = {})
  raise "No counter cache defined on #{self.name}" unless @after_commit_counter_cache

  options[:exclude] = [options[:exclude]] if options[:exclude] && !options[:exclude].is_a?(Enumerable)
  options[:exclude] = options[:exclude].try(:map) {|x| x.is_a?(Enumerable) ? x : [x] }
  options[:only] = [options[:only]] if options[:only] && !options[:only].is_a?(Enumerable)
  options[:only] = options[:only].try(:map) {|x| x.is_a?(Enumerable) ? x : [x] }

  fixed = []
  @after_commit_counter_cache.each do |hash|
    next if options[:exclude] && options[:exclude].include?(hash[:relation])
    next if options[:only] && !options[:only].include?(hash[:relation])

    if options[:skip_unsupported]
      next if (hash[:foreign_key_values] || (hash[:counter_cache_name].is_a?(Proc) && !hash[:column_names]))
    else
      raise "Fixing counter caches is not supported when using :foreign_key_values; you may skip this relation with :skip_unsupported => true" if hash[:foreign_key_values]
      raise "Must provide :column_names option for relation #{hash[:relation].inspect} when :column_name is a Proc; you may skip this relation with :skip_unsupported => true" if hash[:counter_cache_name].is_a?(Proc) && !hash[:column_names]
    end

    # if we're provided a custom set of column names with conditions, use them; just use the
    # column name otherwise
    # which class does this relation ultimately point to? that's where we have to start
    klass = relation_klass(hash[:relation])
    query = klass

    # if a delta column is provided use SUM, otherwise use COUNT
    count_select = hash[:delta_column] ? "SUM(COALESCE(#{self.table_name}.#{hash[:delta_column]},0))" : "COUNT(#{self.table_name}.#{self.primary_key})"

    # respect the deleted_at column if it exists
    query = query.where("#{self.table_name}.deleted_at IS NULL") if self.column_names.include?('deleted_at')

    column_names = hash[:column_names] || {nil => hash[:counter_cache_name]}
    raise ":column_names must be a Hash of conditions and column names" unless column_names.is_a?(Hash)

    # we need to work our way back from the end-point of the relation to this class itself;
    # make a list of arrays pointing to the second-to-last, third-to-last, etc.
    reverse_relation = (1..hash[:relation].length).to_a.reverse.inject([]) {|a,i| a << hash[:relation][0,i]; a }

    # store joins in an array so that we can later apply column-specific conditions
    joins = reverse_relation.map do |cur_relation|
      reflect = relation_reflect(cur_relation)
      joins_query = "LEFT JOIN #{reflect.active_record.table_name} ON #{reflect.table_name}.#{reflect.klass.primary_key} = #{reflect.active_record.table_name}.#{reflect.foreign_key}"
      # adds 'type' condition to JOIN clause if the current model is a child in a Single Table Inheritance
      joins_query = "#{joins_query} AND #{reflect.active_record.table_name}.type IN ('#{self.name}')" if self.column_names.include?('type') and not(self.descends_from_active_record?)
      joins_query
    end

    # iterate over all the possible counter cache column names
    column_names.each do |where, column_name|
      # select id and count (from above) as well as cache column ('column_name') for later comparison
      counts_query = query.select("#{klass.table_name}.#{klass.primary_key}, #{count_select} AS count, #{klass.table_name}.#{column_name}")

      # we need to join together tables until we get back to the table this class itself lives in
      # conditions must also be applied to the join on which we are counting
      joins.each_with_index do |join,index|
        join += " AND (#{sanitize_sql_for_conditions(where)})" if index == joins.size - 1 && where
        counts_query = counts_query.joins(join)
      end

      # iterate in batches; otherwise we might run out of memory when there's a lot of
      # instances and we try to load all their counts at once
      start = 0
      batch_size = options[:batch_size] || 1000

      while (records = counts_query.reorder(full_primary_key(klass) + " ASC").offset(start).limit(batch_size).group(full_primary_key(klass)).to_a).any?
        # now iterate over all the models and see whether their counts are right
        records.each do |model|
          count = model.read_attribute('count') || 0
          if model.read_attribute(column_name) != count
            # keep track of what we fixed, e.g. for a notification email
            fixed<< {
              :entity => klass.name,
              klass.primary_key.to_sym => model.send(klass.primary_key),
              :what => column_name,
              :wrong => model.send(column_name),
              :right => count
            }
            # use update_all because it's faster and because a fixed counter-cache shouldn't
            # update the timestamp
            klass.where(klass.primary_key => model.send(klass.primary_key)).update_all(column_name => count)
          end
        end

        start += batch_size
      end
    end
  end

  return fixed
end