incrdecr_cached_counts 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci-matrix.yml +8 -0
  3. data/.gitignore +9 -0
  4. data/.travis.yml +15 -0
  5. data/Gemfile +21 -0
  6. data/MIT-LICENSE +20 -0
  7. data/README.rdoc +51 -0
  8. data/Rakefile +16 -0
  9. data/circle.yml +10 -0
  10. data/html/CachedCounts/ClassMethods.html +960 -0
  11. data/html/CachedCounts/Railtie.html +102 -0
  12. data/html/CachedCounts.html +197 -0
  13. data/html/README_rdoc.html +152 -0
  14. data/html/created.rid +7 -0
  15. data/html/css/fonts.css +167 -0
  16. data/html/css/rdoc.css +590 -0
  17. data/html/fonts/Lato-Light.ttf +0 -0
  18. data/html/fonts/Lato-LightItalic.ttf +0 -0
  19. data/html/fonts/Lato-Regular.ttf +0 -0
  20. data/html/fonts/Lato-RegularItalic.ttf +0 -0
  21. data/html/fonts/SourceCodePro-Bold.ttf +0 -0
  22. data/html/fonts/SourceCodePro-Regular.ttf +0 -0
  23. data/html/images/add.png +0 -0
  24. data/html/images/arrow_up.png +0 -0
  25. data/html/images/brick.png +0 -0
  26. data/html/images/brick_link.png +0 -0
  27. data/html/images/bug.png +0 -0
  28. data/html/images/bullet_black.png +0 -0
  29. data/html/images/bullet_toggle_minus.png +0 -0
  30. data/html/images/bullet_toggle_plus.png +0 -0
  31. data/html/images/date.png +0 -0
  32. data/html/images/delete.png +0 -0
  33. data/html/images/find.png +0 -0
  34. data/html/images/loadingAnimation.gif +0 -0
  35. data/html/images/macFFBgHack.png +0 -0
  36. data/html/images/package.png +0 -0
  37. data/html/images/page_green.png +0 -0
  38. data/html/images/page_white_text.png +0 -0
  39. data/html/images/page_white_width.png +0 -0
  40. data/html/images/plugin.png +0 -0
  41. data/html/images/ruby.png +0 -0
  42. data/html/images/tag_blue.png +0 -0
  43. data/html/images/tag_green.png +0 -0
  44. data/html/images/transparent.png +0 -0
  45. data/html/images/wrench.png +0 -0
  46. data/html/images/wrench_orange.png +0 -0
  47. data/html/images/zoom.png +0 -0
  48. data/html/index.html +155 -0
  49. data/html/js/darkfish.js +161 -0
  50. data/html/js/jquery.js +4 -0
  51. data/html/js/navigation.js +142 -0
  52. data/html/js/navigation.js.gz +0 -0
  53. data/html/js/search.js +109 -0
  54. data/html/js/search_index.js +1 -0
  55. data/html/js/search_index.js.gz +0 -0
  56. data/html/js/searcher.js +228 -0
  57. data/html/js/searcher.js.gz +0 -0
  58. data/html/table_of_contents.html +144 -0
  59. data/incrdecr_cached_counts.gemspec +31 -0
  60. data/lib/cached_counts/connection_for.rb +14 -0
  61. data/lib/cached_counts/dalli_check.rb +5 -0
  62. data/lib/cached_counts/railtie.rb +7 -0
  63. data/lib/cached_counts/version.rb +3 -0
  64. data/lib/cached_counts.rb +388 -0
  65. data/spec/caches_count_of_spec.rb +142 -0
  66. data/spec/caches_count_where_spec.rb +88 -0
  67. data/spec/database.yml +5 -0
  68. data/spec/fixtures/department.rb +11 -0
  69. data/spec/fixtures/following.rb +7 -0
  70. data/spec/fixtures/university.rb +11 -0
  71. data/spec/fixtures/user.rb +26 -0
  72. data/spec/fixtures.rb +23 -0
  73. data/spec/spec_helper.rb +49 -0
  74. metadata +239 -0
@@ -0,0 +1,5 @@
1
+ if defined?(Rails)
2
+ ActiveSupport.on_load :cached_counts do
3
+ raise "CachedCounts depends on Dalli!" unless Rails.cache.respond_to?(:dalli)
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ module CachedCounts
2
+ class Railtie < Rails::Railtie
3
+ config.after_initialize do
4
+ ActiveSupport.run_load_hooks :cached_counts
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module CachedCounts
2
+ VERSION = "0.0.1"
3
+ end
@@ -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,5 @@
1
+ cached_counts_test:
2
+ adapter: sqlite3
3
+ database: ":memory:"
4
+ pool: 5
5
+ timeout: 5000
@@ -0,0 +1,11 @@
1
+ class Department < ActiveRecord::Base
2
+ include CachedCounts
3
+
4
+ belongs_to :university
5
+ has_many :users, inverse_of: :department
6
+
7
+ caches_count_of :users,
8
+ scope: -> { confirmed },
9
+ if: :confirmed?
10
+
11
+ end
@@ -0,0 +1,7 @@
1
+ class Following < ActiveRecord::Base
2
+ include CachedCounts
3
+
4
+ belongs_to :followee, class_name: 'User'
5
+ belongs_to :follower, class_name: 'User'
6
+
7
+ end
@@ -0,0 +1,11 @@
1
+ class University < ActiveRecord::Base
2
+ include CachedCounts
3
+
4
+ has_many :departments
5
+ has_many :users, through: :departments
6
+
7
+ caches_count_of :users,
8
+ scope: -> { where(confirmed: true) },
9
+ if: ->{ confirmed? }
10
+
11
+ end
@@ -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