incrdecr_cached_counts 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci-matrix.yml +8 -0
- data/.gitignore +9 -0
- data/.travis.yml +15 -0
- data/Gemfile +21 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +51 -0
- data/Rakefile +16 -0
- data/circle.yml +10 -0
- data/html/CachedCounts/ClassMethods.html +960 -0
- data/html/CachedCounts/Railtie.html +102 -0
- data/html/CachedCounts.html +197 -0
- data/html/README_rdoc.html +152 -0
- data/html/created.rid +7 -0
- data/html/css/fonts.css +167 -0
- data/html/css/rdoc.css +590 -0
- data/html/fonts/Lato-Light.ttf +0 -0
- data/html/fonts/Lato-LightItalic.ttf +0 -0
- data/html/fonts/Lato-Regular.ttf +0 -0
- data/html/fonts/Lato-RegularItalic.ttf +0 -0
- data/html/fonts/SourceCodePro-Bold.ttf +0 -0
- data/html/fonts/SourceCodePro-Regular.ttf +0 -0
- data/html/images/add.png +0 -0
- data/html/images/arrow_up.png +0 -0
- data/html/images/brick.png +0 -0
- data/html/images/brick_link.png +0 -0
- data/html/images/bug.png +0 -0
- data/html/images/bullet_black.png +0 -0
- data/html/images/bullet_toggle_minus.png +0 -0
- data/html/images/bullet_toggle_plus.png +0 -0
- data/html/images/date.png +0 -0
- data/html/images/delete.png +0 -0
- data/html/images/find.png +0 -0
- data/html/images/loadingAnimation.gif +0 -0
- data/html/images/macFFBgHack.png +0 -0
- data/html/images/package.png +0 -0
- data/html/images/page_green.png +0 -0
- data/html/images/page_white_text.png +0 -0
- data/html/images/page_white_width.png +0 -0
- data/html/images/plugin.png +0 -0
- data/html/images/ruby.png +0 -0
- data/html/images/tag_blue.png +0 -0
- data/html/images/tag_green.png +0 -0
- data/html/images/transparent.png +0 -0
- data/html/images/wrench.png +0 -0
- data/html/images/wrench_orange.png +0 -0
- data/html/images/zoom.png +0 -0
- data/html/index.html +155 -0
- data/html/js/darkfish.js +161 -0
- data/html/js/jquery.js +4 -0
- data/html/js/navigation.js +142 -0
- data/html/js/navigation.js.gz +0 -0
- data/html/js/search.js +109 -0
- data/html/js/search_index.js +1 -0
- data/html/js/search_index.js.gz +0 -0
- data/html/js/searcher.js +228 -0
- data/html/js/searcher.js.gz +0 -0
- data/html/table_of_contents.html +144 -0
- data/incrdecr_cached_counts.gemspec +31 -0
- data/lib/cached_counts/connection_for.rb +14 -0
- data/lib/cached_counts/dalli_check.rb +5 -0
- data/lib/cached_counts/railtie.rb +7 -0
- data/lib/cached_counts/version.rb +3 -0
- data/lib/cached_counts.rb +388 -0
- data/spec/caches_count_of_spec.rb +142 -0
- data/spec/caches_count_where_spec.rb +88 -0
- data/spec/database.yml +5 -0
- data/spec/fixtures/department.rb +11 -0
- data/spec/fixtures/following.rb +7 -0
- data/spec/fixtures/university.rb +11 -0
- data/spec/fixtures/user.rb +26 -0
- data/spec/fixtures.rb +23 -0
- data/spec/spec_helper.rb +49 -0
- metadata +239 -0
@@ -0,0 +1,388 @@
|
|
1
|
+
require 'cached_counts/dalli_check'
|
2
|
+
require 'cached_counts/connection_for'
|
3
|
+
|
4
|
+
module CachedCounts
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
# Cache the count for a scope in memcached.
|
9
|
+
#
|
10
|
+
# e.g.
|
11
|
+
# User.caches_count_where :confirmed
|
12
|
+
# > User.confirmed_count # User.confirmed.count, but cached
|
13
|
+
#
|
14
|
+
# Automatically adds after_commit hooks which increment/decrement the value
|
15
|
+
# in memcached when needed. Queries the db on cache miss.
|
16
|
+
#
|
17
|
+
# Valid options:
|
18
|
+
# :scope
|
19
|
+
# Name of the scope to count. Defaults to the attribute_name
|
20
|
+
# (the required argument to `caches_count_where`).
|
21
|
+
#
|
22
|
+
# :alias
|
23
|
+
# Alias(es) for the count attribute.
|
24
|
+
# e.g. `caches_count_where :confirmed, alias: 'sitemap'`
|
25
|
+
# > User.sitemap_count
|
26
|
+
#
|
27
|
+
# :expires_in
|
28
|
+
# Expiry for the cached value.
|
29
|
+
#
|
30
|
+
# :if
|
31
|
+
# proc passed through to the after_commit hooks;
|
32
|
+
# decides whether an object counts towards the association total.
|
33
|
+
#
|
34
|
+
# :version
|
35
|
+
# Cache version - bump if you change the definition of a count.
|
36
|
+
#
|
37
|
+
# :race_condition_fallback
|
38
|
+
# Fallback to the result of this proc if the cache is empty, while
|
39
|
+
# loading the actual value from the db. Works similarly to
|
40
|
+
# race_condition_ttl but for empty caches rather than expired values.
|
41
|
+
# Meant to prevent a thundering-herd scenario, if for example a
|
42
|
+
# memcached instance goes away. Can be nil; defaults to using a value
|
43
|
+
# grabbed from the cache or DB at startup.
|
44
|
+
#
|
45
|
+
def caches_count_where(attribute_name, options = {})
|
46
|
+
# Delay actual run to work around circular dependencies
|
47
|
+
klass = self
|
48
|
+
ActiveSupport.on_load :cached_counts do
|
49
|
+
klass.send :caches_count_where!, attribute_name, options
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Cache the count for an association in memcached.
|
54
|
+
#
|
55
|
+
# e.g.
|
56
|
+
# User.caches_count_of :friends
|
57
|
+
# > User.first.friends_count # Users.first.friends.count, but cached
|
58
|
+
#
|
59
|
+
# Automatically adds after_commit hooks to the associated class which
|
60
|
+
# increment/decrement the value in memcached when needed. Queries the db
|
61
|
+
# on cache miss.
|
62
|
+
#
|
63
|
+
# Valid options:
|
64
|
+
# :association
|
65
|
+
# Name of the association to count. Defaults to the attribute_name
|
66
|
+
# (the required argument to `caches_count_of`).
|
67
|
+
#
|
68
|
+
# :alias
|
69
|
+
# Alias(es) for the count attribute. Useful with join tables.
|
70
|
+
# e.g. `caches_count_of :user_departments, alias: 'users'`
|
71
|
+
# > Department.first.users_count
|
72
|
+
#
|
73
|
+
# :expires_in
|
74
|
+
# Expiry for the cached value.
|
75
|
+
#
|
76
|
+
# :if
|
77
|
+
# proc passed through to the after_commit hooks on the counted class;
|
78
|
+
# decides whether an object counts towards the association total.
|
79
|
+
#
|
80
|
+
# :scope
|
81
|
+
# proc used like an ActiveRecord scope on the counted class on cache misses.
|
82
|
+
#
|
83
|
+
# :version
|
84
|
+
# Cache version - bump if you change the definition of a count.
|
85
|
+
#
|
86
|
+
def caches_count_of(attribute_name, options = {})
|
87
|
+
# Delay actual run to work around circular dependencies
|
88
|
+
klass = self
|
89
|
+
ActiveSupport.on_load :cached_counts do
|
90
|
+
klass.send :caches_count_of!, attribute_name, options
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def scope_count_key(attribute_name, version = 1)
|
95
|
+
"#{name}:#{attribute_name}_count:#{version}"
|
96
|
+
end
|
97
|
+
|
98
|
+
def association_count_key(counter_id, attribute_name, version = 1)
|
99
|
+
"#{name}:#{counter_id}:#{attribute_name}_count:#{version}" unless counter_id.nil?
|
100
|
+
end
|
101
|
+
|
102
|
+
protected
|
103
|
+
|
104
|
+
def caches_count_where!(attribute_name, options)
|
105
|
+
scope_name = options.fetch :scope, attribute_name
|
106
|
+
relation = send(scope_name) if respond_to?(scope_name)
|
107
|
+
raise "#{self} does not have a scope named #{scope_name}" unless relation.is_a?(ActiveRecord::Relation)
|
108
|
+
|
109
|
+
define_scope_count_attribute attribute_name, relation, options
|
110
|
+
add_scope_counting_hooks attribute_name, options
|
111
|
+
end
|
112
|
+
|
113
|
+
def caches_count_of!(attribute_name, options)
|
114
|
+
association_name = options.fetch :association, attribute_name
|
115
|
+
association = reflect_on_association(association_name.to_sym)
|
116
|
+
raise "#{self} does not have an association named #{association_name}" unless association
|
117
|
+
|
118
|
+
define_association_count_attribute attribute_name, association, options
|
119
|
+
add_association_counting_hooks attribute_name, association, options
|
120
|
+
end
|
121
|
+
|
122
|
+
def define_scope_count_attribute(attribute_name, relation, options)
|
123
|
+
options = options.dup
|
124
|
+
|
125
|
+
version = options.fetch :version, 1
|
126
|
+
key = scope_count_key(attribute_name, version)
|
127
|
+
|
128
|
+
unless options.has_key?(:race_condition_fallback)
|
129
|
+
options[:race_condition_fallback] = default_race_condition_fallback_proc(
|
130
|
+
key,
|
131
|
+
relation,
|
132
|
+
options
|
133
|
+
)
|
134
|
+
end
|
135
|
+
|
136
|
+
[attribute_name, *Array(options[:alias])].each do |attr_name|
|
137
|
+
add_count_attribute_methods(
|
138
|
+
attr_name,
|
139
|
+
-> { key },
|
140
|
+
-> { relation },
|
141
|
+
:define_singleton_method,
|
142
|
+
self,
|
143
|
+
options
|
144
|
+
)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def default_race_condition_fallback_proc(key, relation, options)
|
149
|
+
fallback = Rails.cache.read(key)
|
150
|
+
fallback = fallback.value if fallback.is_a?(ActiveSupport::Cache::Entry)
|
151
|
+
|
152
|
+
if fallback.nil?
|
153
|
+
begin
|
154
|
+
fallback = relation.count
|
155
|
+
rescue ActiveRecord::StatementInvalid => e
|
156
|
+
fallback = 0
|
157
|
+
end
|
158
|
+
|
159
|
+
Rails.cache.write key, fallback, expires_in: options.fetch(:expires_in, 1.week)
|
160
|
+
end
|
161
|
+
|
162
|
+
-> { fallback }
|
163
|
+
end
|
164
|
+
|
165
|
+
def define_association_count_attribute(attribute_name, association, options)
|
166
|
+
options = options.dup
|
167
|
+
|
168
|
+
version = options.fetch :version, 1
|
169
|
+
key_getter = -> { self.class.association_count_key(id, attribute_name, version) }
|
170
|
+
relation_getter = generate_association_relation_getter(association, options)
|
171
|
+
|
172
|
+
[attribute_name, *Array(options[:alias])].each do |attr_name|
|
173
|
+
define_singleton_method "#{attr_name}_count_key" do |id|
|
174
|
+
association_count_key(id, attribute_name, version)
|
175
|
+
end
|
176
|
+
|
177
|
+
define_singleton_method "#{attr_name}_count_for" do |id|
|
178
|
+
new({id: id}, without_protection: true).send("#{attr_name}_count")
|
179
|
+
end
|
180
|
+
|
181
|
+
add_count_attribute_methods(
|
182
|
+
attr_name,
|
183
|
+
key_getter,
|
184
|
+
relation_getter,
|
185
|
+
:define_method,
|
186
|
+
association.klass,
|
187
|
+
options
|
188
|
+
)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def add_scope_counting_hooks(attribute_name, options)
|
193
|
+
version = options.fetch :version, 1
|
194
|
+
key = scope_count_key(attribute_name, version)
|
195
|
+
|
196
|
+
add_counting_hooks(
|
197
|
+
attribute_name,
|
198
|
+
-> { key },
|
199
|
+
self,
|
200
|
+
options
|
201
|
+
)
|
202
|
+
end
|
203
|
+
|
204
|
+
def add_association_counting_hooks(attribute_name, association, options)
|
205
|
+
key_getter = generate_association_counting_hook_key_getter association, attribute_name, options
|
206
|
+
|
207
|
+
add_counting_hooks(
|
208
|
+
"#{name.demodulize.underscore}_#{attribute_name}",
|
209
|
+
key_getter,
|
210
|
+
association.klass,
|
211
|
+
options
|
212
|
+
)
|
213
|
+
end
|
214
|
+
|
215
|
+
def add_count_attribute_methods(attribute_name, key_getter, relation_getter, define_with, counted_class, options)
|
216
|
+
expires_in = options.fetch :expires_in, 1.week
|
217
|
+
race_condition_fallback = options.fetch :race_condition_fallback, nil
|
218
|
+
|
219
|
+
key_method = "#{attribute_name}_count_key"
|
220
|
+
|
221
|
+
send define_with, key_method, &key_getter
|
222
|
+
|
223
|
+
send define_with, "#{attribute_name}_count" do
|
224
|
+
val = Rails.cache.fetch(
|
225
|
+
send(key_method),
|
226
|
+
expires_in: expires_in,
|
227
|
+
race_condition_ttl: 30.seconds,
|
228
|
+
raw: true # Necessary for incrementing to work correctly
|
229
|
+
) do
|
230
|
+
if race_condition_fallback
|
231
|
+
# Ensure that other reads find something in the cache, but
|
232
|
+
# continue calculating here because the default is likely inaccurate.
|
233
|
+
fallback_value = instance_exec &race_condition_fallback
|
234
|
+
Rails.cache.write(
|
235
|
+
send(key_method),
|
236
|
+
fallback_value.to_i,
|
237
|
+
expires_in: 30.seconds,
|
238
|
+
raw: true
|
239
|
+
)
|
240
|
+
end
|
241
|
+
|
242
|
+
relation = instance_exec(&relation_getter)
|
243
|
+
relation = relation.reorder('')
|
244
|
+
relation.select_values = ['count(*)']
|
245
|
+
|
246
|
+
conn = CachedCounts.connection_for(counted_class)
|
247
|
+
if Rails.version < '4.2'.freeze
|
248
|
+
conn.select_value(relation.to_sql, nil, relation.values[:bind] || []).to_i
|
249
|
+
else
|
250
|
+
conn.select_value(relation.to_sql).to_i
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
if val.is_a?(ActiveSupport::Cache::Entry)
|
255
|
+
val.value.to_i
|
256
|
+
else
|
257
|
+
val.to_i
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
send define_with, "#{attribute_name}_count=" do |value|
|
262
|
+
Rails.cache.write(
|
263
|
+
send(key_method),
|
264
|
+
value.to_i,
|
265
|
+
expires_in: expires_in,
|
266
|
+
raw: true
|
267
|
+
)
|
268
|
+
end
|
269
|
+
|
270
|
+
send define_with, "expire_#{attribute_name}_count" do
|
271
|
+
Rails.cache.delete send(key_method)
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
def add_counting_hooks(attribute_name, key_getter, counted_class, options)
|
276
|
+
increment_hook = "increment_#{attribute_name}_count"
|
277
|
+
counted_class.send :define_method, increment_hook do
|
278
|
+
if (key = instance_exec &key_getter)
|
279
|
+
Rails.cache.increment(
|
280
|
+
key,
|
281
|
+
1,
|
282
|
+
initial: nil # Increment only if the key already exists
|
283
|
+
)
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
decrement_hook = "decrement_#{attribute_name}_count"
|
288
|
+
counted_class.send :define_method, decrement_hook do
|
289
|
+
if (key = instance_exec &key_getter)
|
290
|
+
Rails.cache.decrement(
|
291
|
+
key,
|
292
|
+
1,
|
293
|
+
initial: nil # Decrement only if the key already exists
|
294
|
+
)
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
counted_class.after_commit increment_hook, options.slice(:if).merge(on: :create)
|
299
|
+
|
300
|
+
if (if_proc = options[:if])
|
301
|
+
if if_proc.is_a?(Symbol)
|
302
|
+
if_proc = ->{ send(options[:if]) }
|
303
|
+
end
|
304
|
+
|
305
|
+
recorded_eligibility_var = "@_was_eligible_for_#{attribute_name}_count"
|
306
|
+
counted_class.before_destroy do
|
307
|
+
instance_variable_set recorded_eligibility_var, !!instance_exec(&if_proc)
|
308
|
+
true
|
309
|
+
end
|
310
|
+
counted_class.after_commit on: :destroy do
|
311
|
+
if instance_variable_get(recorded_eligibility_var)
|
312
|
+
send(decrement_hook)
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
counted_class.after_commit on: :update do
|
317
|
+
# There is no before-hook which will reliably have access to the
|
318
|
+
# previous version of the object, so we need to simulate it.
|
319
|
+
previous_values = previous_changes.each_with_object({}) do |(key,vals), memo|
|
320
|
+
memo[key] = vals.first
|
321
|
+
end
|
322
|
+
|
323
|
+
old_version = dup
|
324
|
+
if previous_values.respond_to?(:permitted?)
|
325
|
+
old_version.assign_attributes previous_values, without_protection: true
|
326
|
+
else
|
327
|
+
old_version.assign_attributes previous_values
|
328
|
+
end
|
329
|
+
|
330
|
+
was = !!old_version.instance_exec(&if_proc)
|
331
|
+
is = !!instance_exec(&if_proc)
|
332
|
+
if was != is
|
333
|
+
if is
|
334
|
+
send(increment_hook)
|
335
|
+
else
|
336
|
+
send(decrement_hook)
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
340
|
+
else
|
341
|
+
counted_class.after_commit decrement_hook, on: :destroy
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
def generate_association_counting_hook_key_getter(association, attribute_name, options)
|
346
|
+
version = options.fetch :version, 1
|
347
|
+
counting_class = self
|
348
|
+
|
349
|
+
if association.through_reflection
|
350
|
+
method_chain = association.chain.map do |association|
|
351
|
+
if (source = association.source_reflection)
|
352
|
+
raise "Chained associations without `inverse_of` are not supported!" unless source.inverse_of
|
353
|
+
source.inverse_of.name
|
354
|
+
else
|
355
|
+
association.foreign_key
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
proc do
|
360
|
+
counter_id = method_chain.inject(self) do |memo, method|
|
361
|
+
memo.send(method) unless memo.nil?
|
362
|
+
end
|
363
|
+
counter_id = counter_id.id if counter_id.is_a?(ActiveRecord::Base)
|
364
|
+
|
365
|
+
counting_class.association_count_key counter_id, attribute_name, version
|
366
|
+
end
|
367
|
+
else
|
368
|
+
foreign_key = association.foreign_key
|
369
|
+
|
370
|
+
proc do
|
371
|
+
counting_class.association_count_key send(foreign_key), attribute_name, version
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
376
|
+
def generate_association_relation_getter(association, options)
|
377
|
+
counted_class = association.klass
|
378
|
+
association_name = association.name
|
379
|
+
if (scope_proc = options[:scope])
|
380
|
+
-> { send(association_name).spawn.scoping { counted_class.instance_exec(&scope_proc) } }
|
381
|
+
else
|
382
|
+
-> { send(association_name).spawn }
|
383
|
+
end
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
require 'cached_counts/railtie' if defined?(Rails)
|
@@ -0,0 +1,142 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'caches_count_of' do
|
4
|
+
let!(:university) { University.create! }
|
5
|
+
let!(:confirmed_user_dept) { Department.create! university: university }
|
6
|
+
let!(:unconfirmed_user_dept) { Department.create! university: university }
|
7
|
+
let!(:confirmed_user) { User.create! confirmed: true, department: confirmed_user_dept }
|
8
|
+
let!(:unconfirmed_user) { User.create! confirmed: false, department: unconfirmed_user_dept }
|
9
|
+
let!(:following) { Following.create! follower: unconfirmed_user, followee: confirmed_user }
|
10
|
+
|
11
|
+
it 'counts zero when no records match' do
|
12
|
+
expect(unconfirmed_user.followers_count).to eq(0)
|
13
|
+
expect(unconfirmed_user_dept.users_count).to eq(0)
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'counts one when a record matches' do
|
17
|
+
expect(confirmed_user.followers_count).to eq(1)
|
18
|
+
expect(confirmed_user_dept.users_count).to eq(1)
|
19
|
+
expect(university.users_count).to eq(1)
|
20
|
+
end
|
21
|
+
|
22
|
+
context 'on record update' do
|
23
|
+
it 'increments directly associated count when record becomes matching' do
|
24
|
+
expect {
|
25
|
+
unconfirmed_user.update_attribute :confirmed, true
|
26
|
+
}.to change {
|
27
|
+
unconfirmed_user_dept.users_count
|
28
|
+
}.by 1
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'increments indirectly associated count when record becomes matching' do
|
32
|
+
expect {
|
33
|
+
unconfirmed_user.update_attribute :confirmed, true
|
34
|
+
}.to change {
|
35
|
+
university.users_count
|
36
|
+
}.by 1
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'decrements directly associated count when record becomes non-matching' do
|
40
|
+
expect {
|
41
|
+
confirmed_user.update_attribute :confirmed, false
|
42
|
+
}.to change {
|
43
|
+
confirmed_user_dept.users_count
|
44
|
+
}.by -1
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'decrements indirectly associated count when record becomes non-matching' do
|
48
|
+
expect {
|
49
|
+
confirmed_user.update_attribute :confirmed, false
|
50
|
+
}.to change {
|
51
|
+
university.users_count
|
52
|
+
}.by -1
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
context 'on record creation' do
|
57
|
+
it 'increments count without condition' do
|
58
|
+
added_user = User.create!
|
59
|
+
expect {
|
60
|
+
Following.create! follower: added_user, followee: confirmed_user
|
61
|
+
}.to change {
|
62
|
+
confirmed_user.followers_count
|
63
|
+
}.by 1
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'increments directly associated count when matching' do
|
67
|
+
expect {
|
68
|
+
User.create!(confirmed: true, department: confirmed_user_dept)
|
69
|
+
}.to change {
|
70
|
+
confirmed_user_dept.users_count
|
71
|
+
}.by 1
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'increments indirectly associated count when matching' do
|
75
|
+
expect {
|
76
|
+
User.create!(confirmed: true, department: confirmed_user_dept)
|
77
|
+
}.to change {
|
78
|
+
university.users_count
|
79
|
+
}.by 1
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'does nothing to directly associated count when non-matching' do
|
83
|
+
expect {
|
84
|
+
User.create!(confirmed: false, department: confirmed_user_dept)
|
85
|
+
}.not_to change {
|
86
|
+
confirmed_user_dept.users_count
|
87
|
+
}
|
88
|
+
end
|
89
|
+
|
90
|
+
it 'does nothing to indirectly associated count when non-matching' do
|
91
|
+
expect {
|
92
|
+
User.create!(confirmed: false, department: confirmed_user_dept)
|
93
|
+
}.not_to change {
|
94
|
+
university.users_count
|
95
|
+
}
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
context 'on record destruction' do
|
100
|
+
it 'decrements count without condition' do
|
101
|
+
expect {
|
102
|
+
unconfirmed_user.destroy
|
103
|
+
}.to change {
|
104
|
+
confirmed_user.followers_count
|
105
|
+
}.by -1
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'decrements directly associated count when matching' do
|
109
|
+
expect {
|
110
|
+
confirmed_user.destroy
|
111
|
+
}.to change {
|
112
|
+
confirmed_user_dept.users_count
|
113
|
+
}.by -1
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'decrements indirectly associated count when matching' do
|
117
|
+
expect {
|
118
|
+
confirmed_user.destroy
|
119
|
+
}.to change {
|
120
|
+
university.users_count
|
121
|
+
}.by -1
|
122
|
+
end
|
123
|
+
|
124
|
+
it 'does nothing to directly associated count when non-matching' do
|
125
|
+
expect {
|
126
|
+
unconfirmed_user.destroy
|
127
|
+
}.not_to change {
|
128
|
+
unconfirmed_user_dept.users_count
|
129
|
+
}
|
130
|
+
end
|
131
|
+
|
132
|
+
it 'does nothing to indirectly associated count when non-matching' do
|
133
|
+
expect {
|
134
|
+
unconfirmed_user.destroy
|
135
|
+
}.not_to change {
|
136
|
+
university.users_count
|
137
|
+
}
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
end
|
142
|
+
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'caches_count_where' do
|
4
|
+
let!(:user) { User.create!(confirmed: false) }
|
5
|
+
|
6
|
+
it 'counts zero when no records match' do
|
7
|
+
expect(User.confirmed_count).to eq(0)
|
8
|
+
end
|
9
|
+
|
10
|
+
it 'counts one when a record matches' do
|
11
|
+
expect(User.unconfirmed_count).to eq(1)
|
12
|
+
end
|
13
|
+
|
14
|
+
context 'on record update' do
|
15
|
+
it 'increments when record becomes matching' do
|
16
|
+
expect {
|
17
|
+
user.update_attribute :confirmed, true
|
18
|
+
}.to change {
|
19
|
+
User.confirmed_count
|
20
|
+
}.by 1
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'decrements when record becomes non-matching' do
|
24
|
+
expect {
|
25
|
+
user.update_attribute :confirmed, true
|
26
|
+
}.to change {
|
27
|
+
User.unconfirmed_count
|
28
|
+
}.by -1
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
context 'on record creation' do
|
33
|
+
it 'increments when matching' do
|
34
|
+
expect {
|
35
|
+
User.create!(confirmed: true)
|
36
|
+
}.to change {
|
37
|
+
User.confirmed_count
|
38
|
+
}.by 1
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'does nothing when non-matching' do
|
42
|
+
expect {
|
43
|
+
User.create!(confirmed: true)
|
44
|
+
}.not_to change {
|
45
|
+
User.unconfirmed_count
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context 'on record destruction' do
|
51
|
+
it 'decrements when matching' do
|
52
|
+
expect {
|
53
|
+
user.destroy
|
54
|
+
}.to change {
|
55
|
+
User.unconfirmed_count
|
56
|
+
}.by -1
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'does nothing when non-matching' do
|
60
|
+
expect {
|
61
|
+
user.destroy
|
62
|
+
}.not_to change {
|
63
|
+
User.confirmed_count
|
64
|
+
}
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'is accessible by alias' do
|
69
|
+
expect(User.spammer_count).to eq(1)
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'falls back to value saved on load when cache is empty' do
|
73
|
+
allow(User.unconfirmed).to receive(:count) do
|
74
|
+
# 2nd caller, while calculation is proceeding for first caller, should
|
75
|
+
# get fallback value, rather than joining a thundering herd
|
76
|
+
expect(User.unconfirmed_count).to eq(0)
|
77
|
+
User.where(confirmed: [nil, false]).count
|
78
|
+
end
|
79
|
+
|
80
|
+
# 1st caller--should set the cache to the fallback value, then return
|
81
|
+
# the true value
|
82
|
+
expect(User.unconfirmed_count).to eq(1)
|
83
|
+
|
84
|
+
# 3rd caller, after calculation has finished, should get true value
|
85
|
+
expect(User.unconfirmed_count).to eq(1)
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
data/spec/database.yml
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
class User < ActiveRecord::Base
|
2
|
+
include CachedCounts
|
3
|
+
|
4
|
+
belongs_to :department
|
5
|
+
|
6
|
+
has_many :follower_joins,
|
7
|
+
class_name: 'Following',
|
8
|
+
foreign_key: :followee_id,
|
9
|
+
dependent: :destroy
|
10
|
+
has_many :followers, through: :follower_joins, class_name: 'User'
|
11
|
+
|
12
|
+
has_many :followee_joins,
|
13
|
+
class_name: 'Following',
|
14
|
+
foreign_key: :follower_id,
|
15
|
+
dependent: :destroy
|
16
|
+
has_many :followees, through: :followee_joins, class_name: 'User'
|
17
|
+
|
18
|
+
scope :confirmed, -> { where(confirmed: true) }
|
19
|
+
scope :unconfirmed, -> { where(confirmed: [nil, false]) }
|
20
|
+
|
21
|
+
caches_count_where :confirmed, if: :confirmed?
|
22
|
+
caches_count_where :unconfirmed, if: ->{ !confirmed? }, alias: :spammer
|
23
|
+
|
24
|
+
caches_count_of :follower_joins, alias: :followers
|
25
|
+
|
26
|
+
end
|