counter_culture 0.1.34 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: dc367bb995eff4a72895a3a77c93d1e9f97d8daf
4
- data.tar.gz: 620b7a24d42f57468189b4ee20564dd90b2d3359
3
+ metadata.gz: cfcc2ed5c7a746c8e7bc6c737ba4cf352f026924
4
+ data.tar.gz: 06fc27fd9c7c0799f71387717cf087606897df18
5
5
  SHA512:
6
- metadata.gz: 27145d4edfdec7427e00624849adb265ce80bcb290f5936fcc521e4ac432bcb9c9300a127650cbb272d2445f6da6c7bb3fd20a442cfb2de1ed914aafa44b79c8
7
- data.tar.gz: 3edc8b398be0a698225a11ce418434578e61f9e40c35a2c4a7e8643f56533339d1c356c34fa3eb4c752ad4da2d4e9e9067c5e15e1658907bf48eb3fc35926834
6
+ metadata.gz: 8f152caccc5b4cfaa49aef5a33a94234555384e1dc1ca7f8bfbe7169c15505bfd44d713015cdb32da10a1840685789782f2b8c286792da6e4e7756d7b1238039
7
+ data.tar.gz: ad3d4bfa9a158cafaeab6381b973f987f2380f23c965562345e6ba48d49648167e06177db0f81f28531d62079e57c29a4bf19a89a8b85aac748cf926feb493cb
@@ -1,3 +1,9 @@
1
+ ## 0.2.0 (April 22, 2016)
2
+
3
+ Improvments:
4
+ - Major refactor of the code that reduces ActiveRecord method pollution. Documented API is unchanged, but behind the scenes a lot has changed.
5
+ - Ability to configure batch size of `counter_culture_fix_size`
6
+
1
7
  ## 0.1.34 (October 27, 2015)
2
8
 
3
9
  Bugfixes:
@@ -163,4 +163,4 @@ DEPENDENCIES
163
163
  sqlite3
164
164
 
165
165
  BUNDLED WITH
166
- 1.10.6
166
+ 1.11.2
data/README.md CHANGED
@@ -202,6 +202,17 @@ Product.counter_culture_fix_counts :only => [[:subcategory, :category]]
202
202
  # :except and :only also accept arrays
203
203
  ```
204
204
 
205
+ The ```counter_culture_fix_counts``` counts method uses batch processing of records to keep the memory consumption low. The default batch size is 1000 but is configurable like so
206
+ ```ruby
207
+ # In an initializer
208
+ CounterCulture.config.batch_size = 100
209
+ ```
210
+ or by passing the :batch_size option to the method call
211
+
212
+ ```ruby
213
+ Product.counter_culture_fix_counts :batch_size => 100
214
+ ```
215
+
205
216
  ```counter_culture_fix_counts``` returns an array of hashes of all incorrect values for debugging purposes. The hashes have the following format:
206
217
 
207
218
  ```ruby
@@ -216,7 +227,7 @@ Product.counter_culture_fix_counts :only => [[:subcategory, :category]]
216
227
 
217
228
  #### Handling dynamic column names
218
229
 
219
- Manually populating counter caches with dynammic column names requires additional configuration:
230
+ Manually populating counter caches with dynamic column names requires additional configuration:
220
231
 
221
232
  ```ruby
222
233
  class Product < ActiveRecord::Base
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.34
1
+ 0.2.0
@@ -2,27 +2,26 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: counter_culture 0.1.34 ruby lib
5
+ # stub: counter_culture 0.2.0 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "counter_culture"
9
- s.version = "0.1.34"
9
+ s.version = "0.2.0"
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib"]
13
13
  s.authors = ["Magnus von Koeller"]
14
- s.date = "2015-10-27"
14
+ s.date = "2016-04-22"
15
15
  s.description = "counter_culture provides turbo-charged counter caches that are kept up-to-date not just on create and destroy, that support multiple levels of indirection through relationships, allow dynamic column names and that avoid deadlocks by updating in the after_commit callback."
16
16
  s.email = "magnus@vonkoeller.de"
17
17
  s.extra_rdoc_files = [
18
+ "CHANGELOG.md",
18
19
  "LICENSE.txt",
19
20
  "README.md"
20
21
  ]
21
22
  s.files = [
22
23
  ".document",
23
24
  ".rspec",
24
- ".ruby-gemset",
25
- ".ruby-version",
26
25
  ".travis.yml",
27
26
  "CHANGELOG.md",
28
27
  "Gemfile",
@@ -34,6 +33,9 @@ Gem::Specification.new do |s|
34
33
  "circle.yml",
35
34
  "counter_culture.gemspec",
36
35
  "lib/counter_culture.rb",
36
+ "lib/counter_culture/counter.rb",
37
+ "lib/counter_culture/extensions.rb",
38
+ "lib/counter_culture/reconciler.rb",
37
39
  "lib/generators/counter_culture_generator.rb",
38
40
  "lib/generators/templates/counter_culture_migration.rb.erb",
39
41
  "spec/counter_culture_spec.rb",
@@ -109,7 +111,7 @@ Gem::Specification.new do |s|
109
111
  ]
110
112
  s.homepage = "http://github.com/bestvendor/counter_culture"
111
113
  s.licenses = ["MIT"]
112
- s.rubygems_version = "2.2.5"
114
+ s.rubygems_version = "2.4.5.1"
113
115
  s.summary = "Turbo-charged counter caches for your Rails app."
114
116
 
115
117
  if s.respond_to? :specification_version then
@@ -1,410 +1,19 @@
1
1
  require 'after_commit_action'
2
2
  require 'active_support/concern'
3
3
 
4
- module CounterCulture
5
-
6
- module ActiveRecord
7
- extend ActiveSupport::Concern
8
-
9
- included do
10
- # also add class methods to ActiveRecord::Base
11
- extend ClassMethods
12
- end
13
-
14
- module ClassMethods
15
- # this holds all configuration data
16
- def after_commit_counter_cache
17
- config = @after_commit_counter_cache || []
18
- if superclass.respond_to?(:after_commit_counter_cache) && superclass.after_commit_counter_cache
19
- config = superclass.after_commit_counter_cache + config
20
- end
21
- config
22
- end
23
-
24
- # called to configure counter caches
25
- def counter_culture(relation, options = {})
26
- include AfterCommitAction
27
-
28
- unless @after_commit_counter_cache
29
- # initialize callbacks only once
30
- after_create :_update_counts_after_create
31
- after_destroy :_update_counts_after_destroy
32
- after_update :_update_counts_after_update
33
-
34
- # we keep a list of all counter caches we must maintain
35
- @after_commit_counter_cache = []
36
- end
37
-
38
- # add the current information to our list
39
- @after_commit_counter_cache<< {
40
- :relation => relation.is_a?(Enumerable) ? relation : [relation],
41
- :counter_cache_name => (options[:column_name] || "#{name.tableize}_count"),
42
- :column_names => options[:column_names],
43
- :delta_column => options[:delta_column],
44
- :foreign_key_values => options[:foreign_key_values],
45
- :touch => options[:touch]
46
- }
47
- end
48
-
49
- # checks all of the declared counter caches on this class for correctnes based
50
- # on original data; if the counter cache is incorrect, sets it to the correct
51
- # count
52
- #
53
- # options:
54
- # { :exclude => list of relations to skip when fixing counts,
55
- # :only => only these relations will have their counts fixed }
56
- # returns: a list of fixed record as an array of hashes of the form:
57
- # { :entity => which model the count was fixed on,
58
- # :id => the id of the model that had the incorrect count,
59
- # :what => which column contained the incorrect count,
60
- # :wrong => the previously saved, incorrect count,
61
- # :right => the newly fixed, correct count }
62
- #
63
- def counter_culture_fix_counts(options = {})
64
- raise "No counter cache defined on #{self.name}" unless @after_commit_counter_cache
65
-
66
- options[:exclude] = [options[:exclude]] if options[:exclude] && !options[:exclude].is_a?(Enumerable)
67
- options[:exclude] = options[:exclude].try(:map) {|x| x.is_a?(Enumerable) ? x : [x] }
68
- options[:only] = [options[:only]] if options[:only] && !options[:only].is_a?(Enumerable)
69
- options[:only] = options[:only].try(:map) {|x| x.is_a?(Enumerable) ? x : [x] }
70
-
71
- fixed = []
72
- @after_commit_counter_cache.each do |hash|
73
- next if options[:exclude] && options[:exclude].include?(hash[:relation])
74
- next if options[:only] && !options[:only].include?(hash[:relation])
75
-
76
- if options[:skip_unsupported]
77
- next if (hash[:foreign_key_values] || (hash[:counter_cache_name].is_a?(Proc) && !hash[:column_names]))
78
- else
79
- 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]
80
- 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]
81
- end
82
-
83
- # if we're provided a custom set of column names with conditions, use them; just use the
84
- # column name otherwise
85
- # which class does this relation ultimately point to? that's where we have to start
86
- klass = relation_klass(hash[:relation])
87
- query = klass
88
-
89
- if klass.table_name == self.table_name
90
- self_table_name = "#{self.table_name}_#{self.table_name}"
91
- else
92
- self_table_name = self.table_name
93
- end
94
-
95
- # if a delta column is provided use SUM, otherwise use COUNT
96
- count_select = hash[:delta_column] ? "SUM(COALESCE(#{self_table_name}.#{hash[:delta_column]},0))" : "COUNT(#{self_table_name}.#{self.primary_key})"
97
-
98
- # respect the deleted_at column if it exists
99
- query = query.where("#{self.table_name}.deleted_at IS NULL") if self.column_names.include?('deleted_at')
100
-
101
- column_names = hash[:column_names] || {nil => hash[:counter_cache_name]}
102
- raise ":column_names must be a Hash of conditions and column names" unless column_names.is_a?(Hash)
103
-
104
- # we need to work our way back from the end-point of the relation to this class itself;
105
- # make a list of arrays pointing to the second-to-last, third-to-last, etc.
106
- reverse_relation = (1..hash[:relation].length).to_a.reverse.inject([]) {|a,i| a << hash[:relation][0,i]; a }
107
-
108
- # store joins in an array so that we can later apply column-specific conditions
109
- joins = reverse_relation.map do |cur_relation|
110
- reflect = relation_reflect(cur_relation)
111
- if klass.table_name == reflect.active_record.table_name
112
- join_table_name = "#{klass.table_name}_#{klass.table_name}"
113
- else
114
- join_table_name = reflect.active_record.table_name
115
- end
116
- # join with alias to avoid ambiguous table name with self-referential models:
117
- joins_query = "LEFT JOIN #{reflect.active_record.table_name} AS #{join_table_name} ON #{reflect.table_name}.#{reflect.association_primary_key} = #{join_table_name}.#{reflect.foreign_key}"
118
- # adds 'type' condition to JOIN clause if the current model is a child in a Single Table Inheritance
119
- joins_query = "#{joins_query} AND #{reflect.active_record.table_name}.type IN ('#{self.name}')" if reflect.active_record.column_names.include?('type') and not(self.descends_from_active_record?)
120
- joins_query
121
- end
122
-
123
- # iterate over all the possible counter cache column names
124
- column_names.each do |where, column_name|
125
- # select join column and count (from above) as well as cache column ('column_name') for later comparison
126
- counts_query = query.select("#{klass.table_name}.#{klass.primary_key}, #{klass.table_name}.#{relation_reflect(hash[:relation]).association_primary_key}, #{count_select} AS count, #{klass.table_name}.#{column_name}")
127
-
128
- # we need to join together tables until we get back to the table this class itself lives in
129
- # conditions must also be applied to the join on which we are counting
130
- joins.each_with_index do |join,index|
131
- join += " AND (#{sanitize_sql_for_conditions(where)})" if index == joins.size - 1 && where
132
- counts_query = counts_query.joins(join)
133
- end
134
-
135
- # iterate in batches; otherwise we might run out of memory when there's a lot of
136
- # instances and we try to load all their counts at once
137
- start = 0
138
- batch_size = options[:batch_size] || 1000
139
-
140
- while (records = counts_query.reorder(full_primary_key(klass) + " ASC").offset(start).limit(batch_size).group(full_primary_key(klass)).to_a).any?
141
- # now iterate over all the models and see whether their counts are right
142
- records.each do |model|
143
- count = model.read_attribute('count') || 0
144
- if model.read_attribute(column_name) != count
145
- # keep track of what we fixed, e.g. for a notification email
146
- fixed<< {
147
- :entity => klass.name,
148
- klass.primary_key.to_sym => model.send(klass.primary_key),
149
- :what => column_name,
150
- :wrong => model.send(column_name),
151
- :right => count
152
- }
153
- # use update_all because it's faster and because a fixed counter-cache shouldn't
154
- # update the timestamp
155
- klass.where(klass.primary_key => model.send(klass.primary_key)).update_all(column_name => count)
156
- end
157
- end
158
-
159
- start += batch_size
160
- end
161
- end
162
- end
163
-
164
- return fixed
165
- end
166
-
167
- private
168
- # the string to pass to order() in order to sort by primary key
169
- def full_primary_key(klass)
170
- "#{klass.quoted_table_name}.#{klass.quoted_primary_key}"
171
- end
172
-
173
- # gets the reflect object on the given relation
174
- #
175
- # relation: a symbol or array of symbols; specifies the relation
176
- # that has the counter cache column
177
- def relation_reflect(relation)
178
- relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
179
-
180
- # go from one relation to the next until we hit the last reflect object
181
- klass = self
182
- while relation.size > 0
183
- cur_relation = relation.shift
184
- reflect = klass.reflect_on_association(cur_relation)
185
- raise "No relation #{cur_relation} on #{klass.name}" if reflect.nil?
186
- klass = reflect.klass
187
- end
188
-
189
- return reflect
190
- end
4
+ require 'counter_culture/extensions'
5
+ require 'counter_culture/counter'
6
+ require 'counter_culture/reconciler'
191
7
 
192
- # gets the class of the given relation
193
- #
194
- # relation: a symbol or array of symbols; specifies the relation
195
- # that has the counter cache column
196
- def relation_klass(relation)
197
- relation_reflect(relation).klass
198
- end
199
-
200
- # gets the foreign key name of the given relation
201
- #
202
- # relation: a symbol or array of symbols; specifies the relation
203
- # that has the counter cache column
204
- def relation_foreign_key(relation)
205
- relation_reflect(relation).foreign_key
206
- end
207
-
208
- # gets the primary key name of the given relation
209
- #
210
- # relation: a symbol or array of symbols; specifies the relation
211
- # that has the counter cache column
212
- def relation_primary_key(relation)
213
- relation_reflect(relation).association_primary_key
214
- end
215
-
216
- # gets the foreign key name of the relation. will look at the first
217
- # level only -- i.e., if passed an array will consider only its
218
- # first element
219
- #
220
- # relation: a symbol or array of symbols; specifies the relation
221
- # that has the counter cache column
222
- def first_level_relation_foreign_key(relation)
223
- relation = relation.first if relation.is_a?(Enumerable)
224
- relation_reflect(relation).foreign_key
225
- end
226
-
227
- end
228
-
229
- private
230
- # need to make sure counter_culture is only activated once
231
- # per commit; otherwise, if we do an update in an after_create,
232
- # we would be triggered twice within the same transaction -- once
233
- # for the create, once for the update
234
- def _wrap_in_counter_culture_active(&block)
235
- if @_counter_culture_active
236
- # don't do anything; we are already active for this transaction
237
- else
238
- @_counter_culture_active = true
239
- block.call
240
- execute_after_commit { @_counter_culture_active = false}
241
- end
242
- end
243
-
244
- # called by after_create callback
245
- def _update_counts_after_create
246
- _wrap_in_counter_culture_active do
247
- self.class.after_commit_counter_cache.each do |hash|
248
- # increment counter cache
249
- change_counter_cache(hash.merge(:increment => true))
250
- end
251
- end
252
- end
253
-
254
- # called by after_destroy callback
255
- def _update_counts_after_destroy
256
- _wrap_in_counter_culture_active do
257
- self.class.after_commit_counter_cache.each do |hash|
258
- # decrement counter cache
259
- change_counter_cache(hash.merge(:increment => false))
260
- end
261
- end
262
- end
263
-
264
- # called by after_update callback
265
- def _update_counts_after_update
266
- _wrap_in_counter_culture_active do
267
- self.class.after_commit_counter_cache.each do |hash|
268
- # figure out whether the applicable counter cache changed (this can happen
269
- # with dynamic column names)
270
- counter_cache_name_was = counter_cache_name_for(previous_model, hash[:counter_cache_name])
271
- counter_cache_name = counter_cache_name_for(self, hash[:counter_cache_name])
272
-
273
- if send("#{first_level_relation_foreign_key(hash[:relation])}_changed?") ||
274
- (hash[:delta_column] && send("#{hash[:delta_column]}_changed?")) ||
275
- counter_cache_name != counter_cache_name_was
276
-
277
- # increment the counter cache of the new value
278
- change_counter_cache(hash.merge(:increment => true, :counter_column => counter_cache_name))
279
- # decrement the counter cache of the old value
280
- change_counter_cache(hash.merge(:increment => false, :was => true, :counter_column => counter_cache_name_was))
281
- end
282
- end
283
- end
284
- end
285
-
286
- # increments or decrements a counter cache
287
- #
288
- # options:
289
- # :increment => true to increment, false to decrement
290
- # :relation => which relation to increment the count on,
291
- # :counter_cache_name => the column name of the counter cache
292
- # :counter_column => overrides :counter_cache_name
293
- # :delta_column => override the default count delta (1) with the value of this column in the counted record
294
- # :was => whether to get the current value or the old value of the
295
- # first part of the relation
296
- def change_counter_cache(options)
297
- options[:counter_column] = counter_cache_name_for(self, options[:counter_cache_name]) unless options.has_key?(:counter_column)
298
-
299
- # default to the current foreign key value
300
- id_to_change = foreign_key_value(options[:relation], options[:was])
301
- # allow overwriting of foreign key value by the caller
302
- id_to_change = options[:foreign_key_values].call(id_to_change) if options[:foreign_key_values]
303
-
304
- if id_to_change && options[:counter_column]
305
- delta_magnitude = if options[:delta_column]
306
- delta_attr_name = options[:was] ? "#{options[:delta_column]}_was" : options[:delta_column]
307
- self.send(delta_attr_name) || 0
308
- else
309
- 1
310
- end
311
- execute_after_commit do
312
- # increment or decrement?
313
- operator = options[:increment] ? '+' : '-'
314
-
315
- # we don't use Rails' update_counters because we support changing the timestamp
316
- quoted_column = self.class.connection.quote_column_name(options[:counter_column])
317
-
318
- updates = []
319
- # this updates the actual counter
320
- updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"
321
- # and here we update the timestamp, if so desired
322
- if options[:touch]
323
- current_time = current_time_from_proper_timezone
324
- timestamp_attributes_for_update_in_model.each do |timestamp_column|
325
- updates << "#{timestamp_column} = '#{current_time.to_formatted_s(:db)}'"
326
- end
327
- end
328
-
329
- klass = relation_klass(options[:relation])
330
- klass.where(relation_primary_key(options[:relation]) => id_to_change).update_all updates.join(', ')
331
- end
332
- end
333
- end
334
-
335
- # Gets the name of the counter cache for a specific object
336
- #
337
- # obj: object to calculate the counter cache name for
338
- # cache_name_finder: object used to calculate the cache name
339
- def counter_cache_name_for(obj, cache_name_finder)
340
- # figure out what the column name is
341
- if cache_name_finder.is_a? Proc
342
- # dynamic column name -- call the Proc
343
- cache_name_finder.call(obj)
344
- else
345
- # static column name
346
- cache_name_finder
347
- end
348
- end
349
-
350
- # Creates a copy of the current model with changes rolled back
351
- def previous_model
352
- prev = self.dup
353
-
354
- self.changed_attributes.each_pair do |key, value|
355
- prev.send("#{key}=".to_sym, value)
356
- end
357
-
358
- prev
359
- end
360
-
361
- # gets the value of the foreign key on the given relation
362
- #
363
- # relation: a symbol or array of symbols; specifies the relation
364
- # that has the counter cache column
365
- # was: whether to get the current or past value from ActiveRecord;
366
- # pass true to get the past value, false or nothing to get the
367
- # current value
368
- def foreign_key_value(relation, was = false)
369
- relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
370
- first_relation = relation.first
371
- if was
372
- first = relation.shift
373
- foreign_key_value = send("#{relation_foreign_key(first)}_was")
374
- klass = relation_klass(first)
375
- value = klass.where("#{klass.table_name}.#{relation_primary_key(first)} = ?", foreign_key_value).first if foreign_key_value
376
- else
377
- value = self
378
- end
379
- while !value.nil? && relation.size > 0
380
- value = value.send(relation.shift)
381
- end
382
- return value.try(relation_primary_key(first_relation).to_sym)
383
- end
384
-
385
- def relation_klass(relation)
386
- self.class.send :relation_klass, relation
387
- end
388
-
389
- def relation_reflect(relation)
390
- self.class.send :relation_reflect, relation
391
- end
392
-
393
- def relation_foreign_key(relation)
394
- self.class.send :relation_foreign_key, relation
395
- end
396
-
397
- def relation_primary_key(relation)
398
- self.class.send :relation_primary_key, relation
399
- end
400
-
401
- def first_level_relation_foreign_key(relation)
402
- self.class.send :first_level_relation_foreign_key, relation
403
- end
8
+ module CounterCulture
9
+ mattr_accessor :batch_size
10
+ self.batch_size = 1000
404
11
 
12
+ def self.config
13
+ yield(self) if block_given?
14
+ self
405
15
  end
406
-
407
- # extend ActiveRecord with our own code here
408
- ::ActiveRecord::Base.send :include, ActiveRecord
409
16
  end
410
17
 
18
+ # extend ActiveRecord with our own code here
19
+ ::ActiveRecord::Base.send :include, CounterCulture::Extensions
@@ -0,0 +1,175 @@
1
+ module CounterCulture
2
+ class Counter
3
+ CONFIG_OPTIONS = [ :column_names, :counter_cache_name, :delta_column, :foreign_key_values, :touch ]
4
+
5
+ attr_reader :model, :relation, *CONFIG_OPTIONS
6
+
7
+ def initialize(model, relation, options)
8
+ @model = model
9
+ @relation = relation.is_a?(Enumerable) ? relation : [relation]
10
+
11
+ @counter_cache_name = options.fetch(:column_name, "#{model.name.tableize}_count")
12
+ @column_names = options[:column_names]
13
+ @delta_column = options[:delta_column]
14
+ @foreign_key_values = options[:foreign_key_values]
15
+ @touch = options.fetch(:touch, false)
16
+ end
17
+
18
+ # increments or decrements a counter cache
19
+ #
20
+ # options:
21
+ # :increment => true to increment, false to decrement
22
+ # :relation => which relation to increment the count on,
23
+ # :counter_cache_name => the column name of the counter cache
24
+ # :counter_column => overrides :counter_cache_name
25
+ # :delta_column => override the default count delta (1) with the value of this column in the counted record
26
+ # :was => whether to get the current value or the old value of the
27
+ # first part of the relation
28
+ def change_counter_cache(obj, options)
29
+ change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }
30
+
31
+ # default to the current foreign key value
32
+ id_to_change = foreign_key_value(obj, relation, options[:was])
33
+ # allow overwriting of foreign key value by the caller
34
+ id_to_change = foreign_key_values.call(id_to_change) if foreign_key_values
35
+
36
+ if id_to_change && change_counter_column
37
+ delta_magnitude = if delta_column
38
+ delta_attr_name = options[:was] ? "#{delta_column}_was" : delta_column
39
+ obj.send(delta_attr_name) || 0
40
+ else
41
+ 1
42
+ end
43
+ obj.execute_after_commit do
44
+ # increment or decrement?
45
+ operator = options[:increment] ? '+' : '-'
46
+
47
+ # we don't use Rails' update_counters because we support changing the timestamp
48
+ quoted_column = model.connection.quote_column_name(change_counter_column)
49
+
50
+ updates = []
51
+ # this updates the actual counter
52
+ updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"
53
+ # and here we update the timestamp, if so desired
54
+ if touch
55
+ current_time = obj.send(:current_time_from_proper_timezone)
56
+ obj.send(:timestamp_attributes_for_update_in_model).each do |timestamp_column|
57
+ updates << "#{timestamp_column} = '#{current_time.to_formatted_s(:db)}'"
58
+ end
59
+ end
60
+
61
+ klass = relation_klass(relation)
62
+ klass.where(relation_primary_key(relation) => id_to_change).update_all updates.join(', ')
63
+ end
64
+ end
65
+ end
66
+
67
+ # Gets the name of the counter cache for a specific object
68
+ #
69
+ # obj: object to calculate the counter cache name for
70
+ # cache_name_finder: object used to calculate the cache name
71
+ def counter_cache_name_for(obj)
72
+ # figure out what the column name is
73
+ if counter_cache_name.is_a?(Proc)
74
+ # dynamic column name -- call the Proc
75
+ counter_cache_name.call(obj)
76
+ else
77
+ # static column name
78
+ counter_cache_name
79
+ end
80
+ end
81
+
82
+ # the string to pass to order() in order to sort by primary key
83
+ def full_primary_key(klass)
84
+ "#{klass.quoted_table_name}.#{klass.quoted_primary_key}"
85
+ end
86
+
87
+ # gets the value of the foreign key on the given relation
88
+ #
89
+ # relation: a symbol or array of symbols; specifies the relation
90
+ # that has the counter cache column
91
+ # was: whether to get the current or past value from ActiveRecord;
92
+ # pass true to get the past value, false or nothing to get the
93
+ # current value
94
+ def foreign_key_value(obj, relation, was = false)
95
+ relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
96
+ first_relation = relation.first
97
+ if was
98
+ first = relation.shift
99
+ foreign_key_value = obj.send("#{relation_foreign_key(first)}_was")
100
+ klass = relation_klass(first)
101
+ value = klass.where("#{klass.table_name}.#{relation_primary_key(first)} = ?", foreign_key_value).first if foreign_key_value
102
+ else
103
+ value = obj
104
+ end
105
+ while !value.nil? && relation.size > 0
106
+ value = value.send(relation.shift)
107
+ end
108
+ return value.try(relation_primary_key(first_relation).to_sym)
109
+ end
110
+
111
+ # gets the reflect object on the given relation
112
+ #
113
+ # relation: a symbol or array of symbols; specifies the relation
114
+ # that has the counter cache column
115
+ def relation_reflect(relation)
116
+ relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
117
+
118
+ # go from one relation to the next until we hit the last reflect object
119
+ klass = model
120
+ while relation.size > 0
121
+ cur_relation = relation.shift
122
+ reflect = klass.reflect_on_association(cur_relation)
123
+ raise "No relation #{cur_relation} on #{klass.name}" if reflect.nil?
124
+ klass = reflect.klass
125
+ end
126
+
127
+ return reflect
128
+ end
129
+
130
+ # gets the class of the given relation
131
+ #
132
+ # relation: a symbol or array of symbols; specifies the relation
133
+ # that has the counter cache column
134
+ def relation_klass(relation)
135
+ relation_reflect(relation).klass
136
+ end
137
+
138
+ # gets the foreign key name of the given relation
139
+ #
140
+ # relation: a symbol or array of symbols; specifies the relation
141
+ # that has the counter cache column
142
+ def relation_foreign_key(relation)
143
+ relation_reflect(relation).foreign_key
144
+ end
145
+
146
+ # gets the primary key name of the given relation
147
+ #
148
+ # relation: a symbol or array of symbols; specifies the relation
149
+ # that has the counter cache column
150
+ def relation_primary_key(relation)
151
+ relation_reflect(relation).association_primary_key
152
+ end
153
+
154
+ # gets the foreign key name of the relation. will look at the first
155
+ # level only -- i.e., if passed an array will consider only its
156
+ # first element
157
+ #
158
+ # relation: a symbol or array of symbols; specifies the relation
159
+ # that has the counter cache column
160
+ def first_level_relation_foreign_key
161
+ first_relation = relation.first if relation.is_a?(Enumerable)
162
+ relation_reflect(first_relation).foreign_key
163
+ end
164
+
165
+ def previous_model(obj)
166
+ prev = obj.dup
167
+
168
+ obj.changed_attributes.each do |key, value|
169
+ prev.send("#{key}=", value)
170
+ end
171
+
172
+ prev
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,128 @@
1
+ module CounterCulture
2
+ module Extensions
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ # this holds all configuration data
7
+ def after_commit_counter_cache
8
+ config = @after_commit_counter_cache || []
9
+ if superclass.respond_to?(:after_commit_counter_cache) && superclass.after_commit_counter_cache
10
+ config = superclass.after_commit_counter_cache + config
11
+ end
12
+ config
13
+ end
14
+
15
+ # called to configure counter caches
16
+ def counter_culture(relation, options = {})
17
+ unless @after_commit_counter_cache
18
+ include AfterCommitAction unless include?(AfterCommitAction)
19
+
20
+ # initialize callbacks only once
21
+ after_create :_update_counts_after_create
22
+ after_destroy :_update_counts_after_destroy
23
+ after_update :_update_counts_after_update
24
+
25
+ # we keep a list of all counter caches we must maintain
26
+ @after_commit_counter_cache = []
27
+ end
28
+
29
+ if options[:column_names] && !options[:column_names].is_a?(Hash)
30
+ raise ":column_names must be a Hash of conditions and column names"
31
+ end
32
+
33
+ # add the counter to our collection
34
+ @after_commit_counter_cache << Counter.new(self, relation, options)
35
+ end
36
+
37
+ # checks all of the declared counter caches on this class for correctnes based
38
+ # on original data; if the counter cache is incorrect, sets it to the correct
39
+ # count
40
+ #
41
+ # options:
42
+ # { :exclude => list of relations to skip when fixing counts,
43
+ # :only => only these relations will have their counts fixed }
44
+ # returns: a list of fixed record as an array of hashes of the form:
45
+ # { :entity => which model the count was fixed on,
46
+ # :id => the id of the model that had the incorrect count,
47
+ # :what => which column contained the incorrect count,
48
+ # :wrong => the previously saved, incorrect count,
49
+ # :right => the newly fixed, correct count }
50
+ #
51
+ def counter_culture_fix_counts(options = {})
52
+ raise "No counter cache defined on #{name}" unless @after_commit_counter_cache
53
+
54
+ options[:exclude] = Array(options[:exclude]) if options[:exclude]
55
+ options[:exclude] = options[:exclude].try(:map) {|x| x.is_a?(Enumerable) ? x : [x] }
56
+ options[:only] = [options[:only]] if options[:only] && !options[:only].is_a?(Enumerable)
57
+ options[:only] = options[:only].try(:map) {|x| x.is_a?(Enumerable) ? x : [x] }
58
+
59
+ @after_commit_counter_cache.flat_map do |counter|
60
+ next if options[:exclude] && options[:exclude].include?(counter.relation)
61
+ next if options[:only] && !options[:only].include?(counter.relation)
62
+
63
+ reconciler = CounterCulture::Reconciler.new(counter, options.slice(:skip_unsupported))
64
+ reconciler.reconcile!
65
+ reconciler.changes
66
+ end.compact
67
+ end
68
+ end
69
+
70
+ private
71
+ # need to make sure counter_culture is only activated once
72
+ # per commit; otherwise, if we do an update in an after_create,
73
+ # we would be triggered twice within the same transaction -- once
74
+ # for the create, once for the update
75
+ def _wrap_in_counter_culture_active(&block)
76
+ if @_counter_culture_active
77
+ # don't do anything; we are already active for this transaction
78
+ else
79
+ @_counter_culture_active = true
80
+ block.call
81
+ execute_after_commit { @_counter_culture_active = false}
82
+ end
83
+ end
84
+
85
+ # called by after_create callback
86
+ def _update_counts_after_create
87
+ _wrap_in_counter_culture_active do
88
+ self.class.after_commit_counter_cache.each do |counter|
89
+ # increment counter cache
90
+ counter.change_counter_cache(self, :increment => true)
91
+ end
92
+ end
93
+ end
94
+
95
+ # called by after_destroy callback
96
+ def _update_counts_after_destroy
97
+ _wrap_in_counter_culture_active do
98
+ self.class.after_commit_counter_cache.each do |counter|
99
+ # decrement counter cache
100
+ counter.change_counter_cache(self, :increment => false)
101
+ end
102
+ end
103
+ end
104
+
105
+ # called by after_update callback
106
+ def _update_counts_after_update
107
+ _wrap_in_counter_culture_active do
108
+ self.class.after_commit_counter_cache.each do |counter|
109
+ # figure out whether the applicable counter cache changed (this can happen
110
+ # with dynamic column names)
111
+ counter_cache_name_was = counter.counter_cache_name_for(counter.previous_model(self))
112
+ counter_cache_name = counter.counter_cache_name_for(self)
113
+
114
+ if send("#{counter.first_level_relation_foreign_key}_changed?") ||
115
+ (counter.delta_column && send("#{counter.delta_column}_changed?")) ||
116
+ counter_cache_name != counter_cache_name_was
117
+
118
+ # increment the counter cache of the new value
119
+ counter.change_counter_cache(self, :increment => true, :counter_column => counter_cache_name)
120
+ # decrement the counter cache of the old value
121
+ counter.change_counter_cache(self, :increment => false, :was => true, :counter_column => counter_cache_name_was)
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ end
128
+ end
@@ -0,0 +1,125 @@
1
+ module CounterCulture
2
+ class Reconciler
3
+ attr_reader :counter, :options, :changes
4
+
5
+ delegate :model, :relation, :full_primary_key, :relation_reflect, :to => :counter
6
+ delegate *CounterCulture::Counter::CONFIG_OPTIONS, :to => :counter
7
+
8
+ def initialize(counter, options={})
9
+ @counter, @options = counter, options
10
+
11
+ @changes = []
12
+ @reconciled = false
13
+ end
14
+
15
+ def reconcile!
16
+ return false if @reconciled
17
+
18
+ if options[:skip_unsupported]
19
+ return false if (foreign_key_values || (counter_cache_name.is_a?(Proc) && !column_names))
20
+ else
21
+ raise "Fixing counter caches is not supported when using :foreign_key_values; you may skip this relation with :skip_unsupported => true" if foreign_key_values
22
+ raise "Must provide :column_names option for relation #{relation.inspect} when :column_name is a Proc; you may skip this relation with :skip_unsupported => true" if counter_cache_name.is_a?(Proc) && !column_names
23
+ end
24
+
25
+ # if we're provided a custom set of column names with conditions, use them; just use the
26
+ # column name otherwise
27
+ # which class does this relation ultimately point to? that's where we have to start
28
+
29
+ scope = relation_class
30
+
31
+ # respect the deleted_at column if it exists
32
+ scope = scope.where("#{model.table_name}.deleted_at IS NULL") if model.column_names.include?('deleted_at')
33
+
34
+ counter_column_names = column_names || {nil => counter_cache_name}
35
+
36
+ # iterate over all the possible counter cache column names
37
+ counter_column_names.each do |where, column_name|
38
+ # select join column and count (from above) as well as cache column ('column_name') for later comparison
39
+ counts_query = scope.select("#{relation_class.table_name}.#{relation_class.primary_key}, #{relation_class.table_name}.#{relation_reflect(relation).association_primary_key}, #{count_select} AS count, #{relation_class.table_name}.#{column_name}")
40
+
41
+ # we need to join together tables until we get back to the table this class itself lives in
42
+ # conditions must also be applied to the join on which we are counting
43
+ join_clauses.each_with_index do |join,index|
44
+ if index == join_clauses.size - 1 && where
45
+ join += " AND (#{model.send(:sanitize_sql_for_conditions, where)})"
46
+ end
47
+ counts_query = counts_query.joins(join)
48
+ end
49
+
50
+ # iterate in batches; otherwise we might run out of memory when there's a lot of
51
+ # instances and we try to load all their counts at once
52
+ batch_size = options.fetch(:batch_size, CounterCulture.config.batch_size)
53
+
54
+ counts_query.group(full_primary_key(relation_class)).find_in_batches(batch_size: batch_size) do |records|
55
+ # now iterate over all the models and see whether their counts are right
56
+ records.each do |record|
57
+ count = record.read_attribute('count') || 0
58
+ next if record.read_attribute(column_name) == count
59
+
60
+ track_change(record, column_name, count)
61
+
62
+ # use update_all because it's faster and because a fixed counter-cache shouldn't update the timestamp
63
+ relation_class.where(relation_class.primary_key => record.send(relation_class.primary_key)).update_all(column_name => count)
64
+ end
65
+ end
66
+ end
67
+
68
+ @reconciled = true
69
+ end
70
+
71
+ private
72
+
73
+ # keep track of what we fixed, e.g. for a notification email
74
+ def track_change(record, column_name, count)
75
+ @changes << {
76
+ :entity => relation_class.name,
77
+ relation_class.primary_key.to_sym => record.send(relation_class.primary_key),
78
+ :what => column_name,
79
+ :wrong => record.send(column_name),
80
+ :right => count
81
+ }
82
+ end
83
+
84
+ def count_select
85
+ # if a delta column is provided use SUM, otherwise use COUNT
86
+ @count_select ||= delta_column ? "SUM(COALESCE(#{self_table_name}.#{delta_column},0))" : "COUNT(#{self_table_name}.#{model.primary_key})"
87
+ end
88
+
89
+ def relation_class
90
+ @relation_class ||= counter.relation_klass(counter.relation)
91
+ end
92
+
93
+ def self_table_name
94
+ @self_table_name ||= if relation_class.table_name == model.table_name
95
+ "#{model.table_name}_#{model.table_name}"
96
+ else
97
+ model.table_name
98
+ end
99
+ end
100
+
101
+ def join_clauses
102
+ return @join_clauses if defined?(@join_clauses)
103
+
104
+ # we need to work our way back from the end-point of the relation to this class itself;
105
+ # make a list of arrays pointing to the second-to-last, third-to-last, etc.
106
+ reverse_relation = (1..relation.length).to_a.reverse.inject([]) {|a,i| a << relation[0,i]; a }
107
+
108
+ # store joins in an array so that we can later apply column-specific conditions
109
+ @join_clauses = reverse_relation.map do |cur_relation|
110
+ reflect = relation_reflect(cur_relation)
111
+ if relation_class.table_name == reflect.active_record.table_name
112
+ join_table_name = "#{relation_class.table_name}_#{relation_class.table_name}"
113
+ else
114
+ join_table_name = reflect.active_record.table_name
115
+ end
116
+ # join with alias to avoid ambiguous table name with self-referential models:
117
+ joins_sql = "LEFT JOIN #{reflect.active_record.table_name} AS #{join_table_name} ON #{reflect.table_name}.#{reflect.association_primary_key} = #{join_table_name}.#{reflect.foreign_key}"
118
+ # adds 'type' condition to JOIN clause if the current model is a child in a Single Table Inheritance
119
+ joins_sql = "#{joins_sql} AND #{reflect.active_record.table_name}.type IN ('#{model.name}')" if reflect.active_record.column_names.include?('type') && !model.descends_from_active_record?
120
+ joins_sql
121
+ end
122
+ end
123
+
124
+ end
125
+ end
@@ -1371,7 +1371,7 @@ describe "CounterCulture" do
1371
1371
  categ.reload.posts_count.should == 1
1372
1372
  end
1373
1373
 
1374
- describe "#previous_model" do
1374
+ pending "#previous_model" do
1375
1375
  let(:user){User.create :name => "John Smith", :manages_company_id => 1}
1376
1376
 
1377
1377
  it "should return a copy of the original model" do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: counter_culture
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.34
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Magnus von Koeller
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-10-27 00:00:00.000000000 Z
11
+ date: 2016-04-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: after_commit_action
@@ -158,13 +158,12 @@ email: magnus@vonkoeller.de
158
158
  executables: []
159
159
  extensions: []
160
160
  extra_rdoc_files:
161
+ - CHANGELOG.md
161
162
  - LICENSE.txt
162
163
  - README.md
163
164
  files:
164
165
  - ".document"
165
166
  - ".rspec"
166
- - ".ruby-gemset"
167
- - ".ruby-version"
168
167
  - ".travis.yml"
169
168
  - CHANGELOG.md
170
169
  - Gemfile
@@ -176,6 +175,9 @@ files:
176
175
  - circle.yml
177
176
  - counter_culture.gemspec
178
177
  - lib/counter_culture.rb
178
+ - lib/counter_culture/counter.rb
179
+ - lib/counter_culture/extensions.rb
180
+ - lib/counter_culture/reconciler.rb
179
181
  - lib/generators/counter_culture_generator.rb
180
182
  - lib/generators/templates/counter_culture_migration.rb.erb
181
183
  - spec/counter_culture_spec.rb
@@ -268,7 +270,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
268
270
  version: '0'
269
271
  requirements: []
270
272
  rubyforge_project:
271
- rubygems_version: 2.2.5
273
+ rubygems_version: 2.4.5.1
272
274
  signing_key:
273
275
  specification_version: 4
274
276
  summary: Turbo-charged counter caches for your Rails app.
@@ -1 +0,0 @@
1
- counter_culture
@@ -1 +0,0 @@
1
- 2.0.0