counter_culture 0.1.34 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/Gemfile.lock +1 -1
- data/README.md +12 -1
- data/VERSION +1 -1
- data/counter_culture.gemspec +8 -6
- data/lib/counter_culture.rb +11 -402
- data/lib/counter_culture/counter.rb +175 -0
- data/lib/counter_culture/extensions.rb +128 -0
- data/lib/counter_culture/reconciler.rb +125 -0
- data/spec/counter_culture_spec.rb +1 -1
- metadata +7 -5
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA1:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cfcc2ed5c7a746c8e7bc6c737ba4cf352f026924
|
|
4
|
+
data.tar.gz: 06fc27fd9c7c0799f71387717cf087606897df18
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8f152caccc5b4cfaa49aef5a33a94234555384e1dc1ca7f8bfbe7169c15505bfd44d713015cdb32da10a1840685789782f2b8c286792da6e4e7756d7b1238039
|
|
7
|
+
data.tar.gz: ad3d4bfa9a158cafaeab6381b973f987f2380f23c965562345e6ba48d49648167e06177db0f81f28531d62079e57c29a4bf19a89a8b85aac748cf926feb493cb
|
data/CHANGELOG.md
CHANGED
|
@@ -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:
|
data/Gemfile.lock
CHANGED
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
|
|
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
|
+
0.2.0
|
data/counter_culture.gemspec
CHANGED
|
@@ -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.
|
|
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.
|
|
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 = "
|
|
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.
|
|
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
|
data/lib/counter_culture.rb
CHANGED
|
@@ -1,410 +1,19 @@
|
|
|
1
1
|
require 'after_commit_action'
|
|
2
2
|
require 'active_support/concern'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
data/.ruby-gemset
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
counter_culture
|
data/.ruby-version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
2.0.0
|