custom_counter_cache 0.3.1 → 0.4.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.
data/test/counter_test.rb DELETED
@@ -1,282 +0,0 @@
1
- require File.join(File.dirname(__FILE__), 'test_helper')
2
-
3
- class CounterTest < Minitest::Test
4
-
5
- def setup
6
- @user = User.create
7
- @box = Box.create
8
- Counter.destroy_all
9
- end
10
-
11
- def test_default_counter_value
12
- assert_equal 0, @user.published_count
13
- assert_equal 0, @box.green_balls_count
14
- end
15
-
16
- def test_create_and_destroy_counter
17
- @user.articles.create(state: 'published')
18
- assert_equal 1, Counter.count
19
- @user.destroy
20
- assert_equal 0, Counter.count
21
- end
22
-
23
- def test_create_and_destroy_polymorphic_association_counter
24
- @article = @user.articles.create(state: "published")
25
- assert_equal 0, @article.comments.size
26
- @comment = @article.comments.create(state: "published")
27
- assert_equal 1, @article.comments.size
28
- @article.destroy
29
- assert_equal 0, @article.comments.size
30
- end
31
-
32
- def test_increment_and_decrement_counter_with_conditions
33
- @article = @user.articles.create(state: 'unpublished')
34
- assert_equal 0, @user.published_count
35
- @article.update_attribute :state, 'published'
36
- assert_equal 1, @user.published_count
37
- 3.times { |i| @user.articles.create(state: 'published') }
38
- assert_equal 4, @user.published_count
39
- @user.articles.each {|a| a.update(state: 'unpublished') }
40
- assert_equal 0, @user.published_count
41
- end
42
-
43
- def test_increment_and_decrement_polymorphic_counter_with_conditions
44
- @article = @user.articles.create(state: "published")
45
- @comment = @article.comments.create(state: "unpublished")
46
- assert_equal 0, @article.comments_count
47
- @comment.update_attribute :state, "published"
48
- assert_equal 1, @article.comments_count
49
- 3.times { |i| @article.comments.create(state: "published") }
50
- assert_equal 4, @article.comments_count
51
- @article.comments.each { |c| c.update(state: 'unpublished') }
52
- assert_equal 0, @article.comments_count
53
- end
54
-
55
- def test_increment_and_decrement_counter_with_conditions_on_model_with_counter_column
56
- @ball = @box.balls.create(color: 'red')
57
- assert_equal 0, @box.reload.green_balls_count
58
- @ball.update_attribute :color, 'green'
59
- assert_equal 1, @box.reload.green_balls_count
60
- 3.times { |i| @box.balls.create(color: 'green') }
61
- assert_equal 4, @box.reload.green_balls_count
62
- @box.balls.each {|b| b.update(color: 'red') }
63
- assert_equal 0, @box.reload.green_balls_count
64
- end
65
-
66
- # Test that an eager loaded
67
- def test_eager_loading_with_no_counter
68
- @article = @user.articles.create(state: 'unpublished')
69
- user = User.includes(:counters).first
70
- assert_equal 0, user.published_count
71
-
72
- end
73
-
74
- def test_eager_loading_with_counter
75
- @article = @user.articles.create(state: 'published')
76
- @user = User.includes(:counters).find(@user.id)
77
- assert_equal 1, @user.published_count
78
- end
79
-
80
- def test_except_option
81
- @ball = @box.balls.create
82
- assert_equal 1, @box.reload.lifetime_balls_count
83
- @ball.update(color: 'green')
84
- assert_equal 1, @box.reload.lifetime_balls_count
85
- @ball.destroy
86
- assert_equal 1, @box.reload.lifetime_balls_count
87
- end
88
-
89
- def test_only_option
90
- @ball = @box.balls.create
91
- assert_equal 0, @box.reload.destroyed_balls_count
92
- @ball.update(color: 'green')
93
- assert_equal 0, @box.reload.destroyed_balls_count
94
- @ball.destroy
95
- assert_equal 1, @box.reload.destroyed_balls_count
96
- end
97
-
98
- def test_reassigning_article_to_different_user_updates_both_counters
99
- @user2 = User.create
100
- @article = @user.articles.create(state: 'unpublished')
101
- assert_equal 0, @user.reload.published_count
102
- assert_equal 0, @user2.reload.published_count
103
- # Changing both user and state triggers the :if condition (state changed)
104
- # and the reassignment logic inside the callback updates the old user's counter too
105
- @article.update(user: @user2, state: 'published')
106
- assert_equal 0, @user.reload.published_count
107
- assert_equal 1, @user2.reload.published_count
108
- end
109
-
110
- # -- destroy loop regression --
111
- #
112
- # A consumer's Counter model might mistakenly add `dependent: :destroy` to
113
- # its `belongs_to :countable` (see test_helper.rb's Counter class and the
114
- # README "Note" on this). On a belongs_to, dependent: :destroy means "when
115
- # I am destroyed, also destroy the record I belong to" -- the opposite of
116
- # what a counter cache needs. Combined with the has_many :counters this gem
117
- # defines on the countable side, that mistake used to create a destroy
118
- # loop: destroying a countable record cascaded into destroying its
119
- # Counters, and each Counter's (wrongly configured) belongs_to cascaded
120
- # back into destroying the same countable record again, re-running its
121
- # full before_destroy chain against already-destroyed associations.
122
- #
123
- # has_many :counters now uses dependent: :delete_all, which deletes Counter
124
- # rows with a single SQL statement and never instantiates them or runs
125
- # their callbacks -- so a misconfigured Counter#belongs_to (like the one in
126
- # this test suite's fixtures, deliberately) can no longer cause the loop.
127
-
128
- def test_destroying_a_countable_record_with_a_dependent_association_does_not_recurse
129
- @user.articles.create!(state: 'published') # gives the user a real Counter row
130
- note = UserNote.create!(user: @user)
131
-
132
- @user.destroy!
133
-
134
- assert @user.destroyed?
135
- refute UserNote.exists?(note.id)
136
- end
137
-
138
- def test_counters_are_removed_via_a_single_delete_without_instantiating_them
139
- @user.articles.create!(state: 'published')
140
- assert_equal 1, Counter.where(countable: @user).count
141
-
142
- # dependent: :delete_all must never call Counter#destroy -- prove it by
143
- # making Counter#destroy raise, then destroying the user anyway.
144
- Counter.define_method(:destroy) { raise 'Counter#destroy should not be called' }
145
-
146
- @user.destroy!
147
-
148
- assert_equal 0, Counter.where(countable_type: 'User', countable_id: @user.id).count
149
- ensure
150
- Counter.send(:remove_method, :destroy) if Counter.instance_methods(false).include?(:destroy)
151
- end
152
-
153
- # -- update_counter_cache: polymorphic reassignment --
154
- #
155
- # For a polymorphic association, the old owner must be looked up by its
156
- # old *type* and old *id* together -- either can change independently
157
- # (moving to a different record of the same type only changes the id;
158
- # moving to a different type changes both).
159
-
160
- def test_reassigning_a_polymorphic_association_updates_both_old_and_new_owner_counters
161
- article1 = @user.articles.create!(state: 'unpublished')
162
- article2 = @user.articles.create!(state: 'unpublished')
163
- comment = article1.comments.create!(state: 'unpublished')
164
- comment.update!(state: 'published')
165
- assert_equal 1, article1.reload.comments_count
166
- assert_equal 0, article2.reload.comments_count
167
-
168
- # The :if condition only fires the callback on a state change, so toggle
169
- # state alongside the reassignment -- same pattern as
170
- # test_reassigning_article_to_different_user_updates_both_counters above.
171
- comment.update!(commentable: article2, state: 'unpublished')
172
- comment.update!(state: 'published')
173
-
174
- assert_equal 0, article1.reload.comments_count
175
- assert_equal 1, article2.reload.comments_count
176
- end
177
-
178
- # -- rescue StandardError / Heroku DATABASE_URL guard --
179
- #
180
- # Both class methods wrap their body in a rescue that swallows any
181
- # StandardError raised while table_exists? can't reach a database (e.g.
182
- # during `assets:precompile` on Heroku, where there is no DB connection at
183
- # all), but only when DATABASE_URL matches Heroku's placeholder value.
184
- # Anywhere else, the error must still propagate.
185
-
186
- def with_stubbed_table_exists(klass, error)
187
- klass.define_singleton_method(:table_exists?) { raise error }
188
- yield
189
- ensure
190
- klass.singleton_class.send(:remove_method, :table_exists?)
191
- end
192
-
193
- def test_define_counter_cache_reraises_when_database_url_is_not_the_heroku_placeholder
194
- klass = Class.new(ApplicationRecord) { self.table_name = 'users' }
195
- with_stubbed_table_exists(klass, 'no database connection') do
196
- assert_raises(RuntimeError) { klass.define_counter_cache(:whatever) { |r| 0 } }
197
- end
198
- end
199
-
200
- def test_define_counter_cache_swallows_error_when_database_url_is_the_heroku_placeholder
201
- klass = Class.new(ApplicationRecord) { self.table_name = 'users' }
202
- with_env('DATABASE_URL', 'postgres://user:pass@127.0.0.1/dbname') do
203
- with_stubbed_table_exists(klass, 'no database connection') do
204
- klass.define_counter_cache(:whatever) { |r| 0 } # must not raise
205
- end
206
- end
207
- refute klass.method_defined?(:whatever)
208
- end
209
-
210
- def test_update_counter_cache_reraises_when_database_url_is_not_the_heroku_placeholder
211
- klass = Class.new(ApplicationRecord) { self.table_name = 'articles' }
212
- with_stubbed_table_exists(klass, 'no database connection') do
213
- assert_raises(RuntimeError) { klass.update_counter_cache(:user, :whatever) }
214
- end
215
- end
216
-
217
- def test_update_counter_cache_swallows_error_when_database_url_is_the_heroku_placeholder
218
- klass = Class.new(ApplicationRecord) { self.table_name = 'articles' }
219
- with_env('DATABASE_URL', 'postgres://user:pass@127.0.0.1/dbname') do
220
- with_stubbed_table_exists(klass, 'no database connection') do
221
- klass.update_counter_cache(:user, :whatever) # must not raise
222
- end
223
- end
224
- end
225
-
226
- def with_env(key, value)
227
- original = ENV[key]
228
- ENV[key] = value
229
- yield
230
- ensure
231
- ENV[key] = original
232
- end
233
-
234
- # -- table_exists? early return (no error, just a missing table) --
235
-
236
- def test_define_counter_cache_is_a_no_op_when_the_table_does_not_exist
237
- klass = Class.new(ApplicationRecord) { self.table_name = 'nonexistent_table_xyz' }
238
- klass.define_counter_cache(:whatever) { |r| 0 }
239
- refute klass.method_defined?(:whatever)
240
- refute klass.method_defined?(:update_whatever)
241
- end
242
-
243
- def test_update_counter_cache_is_a_no_op_when_the_table_does_not_exist
244
- klass = Class.new(ApplicationRecord) { self.table_name = 'nonexistent_table_xyz' }
245
- klass.update_counter_cache(:user, :whatever)
246
- refute klass.method_defined?(:callback_user_whatever)
247
- end
248
-
249
- # -- update_counter_cache: :unless option --
250
-
251
- def test_unless_option_skips_the_callback_when_true
252
- @ball = @box.balls.create(color: 'red')
253
- assert_equal 1, @box.reload.non_green_balls_count
254
- @ball.update(color: 'green')
255
- # :unless suppresses the callback here, so the count is stale (still 1)
256
- # rather than recomputed to 0 -- proving the callback did not run.
257
- assert_equal 1, @box.reload.non_green_balls_count
258
- end
259
-
260
- def test_unless_option_runs_the_callback_when_false
261
- @ball = @box.balls.create(color: 'green')
262
- @ball.update(color: 'red')
263
- assert_equal 1, @box.reload.non_green_balls_count
264
- end
265
-
266
- # -- update_counter_cache: :prepend option --
267
- #
268
- # NOTE: this checks that :prepend is forwarded to the after_create callback
269
- # chain (the gem's own responsibility), not that it changes visible
270
- # execution order -- that's a separate, Rails-internals quirk: plain
271
- # sequential after_* callbacks in this Rails version don't necessarily run
272
- # in chain order just because one was prepended (confirmed with a bare
273
- # `after_create ..., prepend: true` outside this gem entirely).
274
-
275
- def test_prepend_option_is_forwarded_to_the_callback_chain
276
- after_create_filters = Ball._create_callbacks.select { |cb| cb.kind == :after }.map(&:filter)
277
- assert_operator after_create_filters.index(:callback_box_marker_b_count),
278
- :<, after_create_filters.index(:callback_box_marker_a_count),
279
- 'expected the prepend: true callback (marker_b) to be ordered before the non-prepended one (marker_a)'
280
- end
281
-
282
- end
data/test/test_helper.rb DELETED
@@ -1,138 +0,0 @@
1
- require 'rubygems'
2
- require 'minitest/autorun'
3
- require 'sqlite3'
4
- require 'action_view'
5
- require 'active_record'
6
- require 'custom_counter_cache'
7
-
8
- ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
9
-
10
- ActiveRecord::Schema.define(version: 1) do
11
- create_table :users do |t|
12
- end
13
-
14
- create_table :articles do |t|
15
- t.belongs_to :user
16
- t.string :state, default: 'unpublished'
17
- end
18
-
19
- create_table :comments do |t|
20
- t.belongs_to :user
21
- t.references :commentable, polymorphic: true
22
- t.string :state, default: "unpublished"
23
- end
24
-
25
- create_table :counters do |t|
26
- t.references :countable, polymorphic: true
27
- t.string :key, null: false
28
- t.integer :value, null: false, default: 0
29
- end
30
- add_index :counters, [ :countable_id, :countable_type, :key ], unique: true
31
-
32
- create_table :boxes do |t|
33
- t.integer :green_balls_count, default: 0
34
- t.integer :lifetime_balls_count, default: 0
35
- t.integer :destroyed_balls_count, default: 0
36
- end
37
-
38
- create_table :balls do |t|
39
- t.belongs_to :box
40
- t.string :color, default: 'red'
41
- end
42
-
43
- create_table :user_notes do |t|
44
- t.belongs_to :user
45
- end
46
-
47
- create_table :user_note_logs do |t|
48
- t.belongs_to :user_note
49
- end
50
- end
51
-
52
- class ApplicationRecord < ActiveRecord::Base
53
- self.abstract_class = true
54
- include CustomCounterCache::Model
55
- end
56
-
57
- class User < ApplicationRecord
58
- has_many :articles, dependent: :destroy
59
- has_one :user_note, dependent: :destroy
60
- define_counter_cache :published_count do |user|
61
- user.articles.where(articles: { state: 'published' }).count
62
- end
63
- end
64
-
65
- class Article < ApplicationRecord
66
- belongs_to :user
67
- update_counter_cache :user, :published_count, if: Proc.new { |article| article.saved_change_to_attribute?(:state) }
68
- has_many :comments, as: :commentable, dependent: :destroy
69
- define_counter_cache :comments_count do |article|
70
- article.comments.where(state: "published").count
71
- end
72
- end
73
-
74
- class Comment < ApplicationRecord
75
- belongs_to :commentable, polymorphic: true
76
- update_counter_cache :commentable, :comments_count, if: Proc.new { |comment| comment.saved_change_to_attribute?(:state) }
77
- end
78
-
79
- class Counter < ApplicationRecord
80
- # Deliberately mirrors a real-world consumer mistake: adding dependent: :destroy
81
- # to this belongs_to (see README "Note"). It must NOT cause destroying a
82
- # countable record to recurse back into destroying itself a second time --
83
- # see DestroyLoopTest, which is what actually guards against this.
84
- belongs_to :countable, polymorphic: true, dependent: :destroy
85
- end
86
-
87
- class Box < ApplicationRecord
88
- has_many :balls
89
- define_counter_cache :green_balls_count do |box|
90
- box.balls.green.count
91
- end
92
- define_counter_cache :lifetime_balls_count do |box|
93
- box.lifetime_balls_count + 1
94
- end
95
- define_counter_cache :destroyed_balls_count do |box|
96
- box.destroyed_balls_count + 1
97
- end
98
- define_counter_cache :non_green_balls_count do |box|
99
- box.balls.where.not(color: 'green').count
100
- end
101
- define_counter_cache :marker_a_count do |box|
102
- 0
103
- end
104
- define_counter_cache :marker_b_count do |box|
105
- 0
106
- end
107
- end
108
-
109
- class Ball < ApplicationRecord
110
- belongs_to :box
111
- scope :green, lambda { where(color: 'green') }
112
- update_counter_cache :box, :green_balls_count, if: Proc.new { |ball| ball.saved_change_to_attribute?(:color) }
113
- update_counter_cache :box, :lifetime_balls_count, except: [:update, :destroy]
114
- update_counter_cache :box, :destroyed_balls_count, only: [:destroy]
115
- update_counter_cache :box, :non_green_balls_count, unless: Proc.new { |ball| ball.color == 'green' }
116
- update_counter_cache :box, :marker_a_count, only: [:create]
117
- update_counter_cache :box, :marker_b_count, only: [:create], prepend: true
118
- end
119
-
120
- # Stands in for something like the `audited` gem: a has_one association whose
121
- # own destroy writes a dependent log record from a before_destroy callback
122
- # (matching how `audited` hooks in -- before_destroy, not after_destroy, so
123
- # the record is still persisted? while the callback runs). If destroying the
124
- # owner (User) ever recurses back into destroying itself a second time, this
125
- # fails the second time around because the UserNote is no longer persisted.
126
- class UserNote < ApplicationRecord
127
- belongs_to :user
128
- has_many :logs, class_name: 'UserNoteLog', foreign_key: :user_note_id, dependent: :destroy
129
- before_destroy :write_log
130
-
131
- def write_log
132
- logs.create!
133
- end
134
- end
135
-
136
- class UserNoteLog < ApplicationRecord
137
- belongs_to :user_note
138
- end