custom_counter_cache 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e36d2910a85a7b6354c086adad83820f1503947aba48f3caeb3c09b424b565d5
4
- data.tar.gz: 6e8892f7c9a9d447117ff9ae0c7c23f16afc00739e93e4b1fd407631989fa0eb
3
+ metadata.gz: b86370bc27fe59b1bafc3f2160e69843c9650e58b6c9e980010eb5f88fb229cd
4
+ data.tar.gz: 0aeccd374cf039a73f248fa4440f3d472859f73f83b4a0f991b432eaba7bf10e
5
5
  SHA512:
6
- metadata.gz: 7986c0f65fd6c43a38a71f4acfd6d62a7bd36e9aeeb03dbe613dff4553d1c0119cd88b48a1c6d19adffd06e4cfd214462202fc8aafe84e8e05d2034288219750
7
- data.tar.gz: f52a1259232d2ae16a2117ea42f0950a41f81379892387c7f4191ec595918750b87b4f9b482223faaac03d19c50f459cd4af610523c89428bd38635c25ac0f41
6
+ metadata.gz: f0de231b5e888db121d43d3a37909e37b8e3cb1fd4bf0c58ad0195bda8030f4937a1aba70729716c6c23f3ec1aa48888f68308bb998d6e50fe8bcb71529d9bde
7
+ data.tar.gz: 721794ae2b6cb35349209392f390d42352d3f0fa31a10599b7c13e7dd98e00bdba7c29c96ccfd9033d99e4c508aeab845ec2db9e653bd93f21238fb899892d12
@@ -9,7 +9,11 @@ module CustomCounterCache::Model
9
9
 
10
10
  # counter accessors
11
11
  unless column_names.include?(cache_column.to_s)
12
- has_many :counters, as: :countable, dependent: :destroy
12
+ # Only declare the :counters association once per class -- a model
13
+ # can have multiple virtual (non-column) counter caches, and
14
+ # redeclaring has_many :counters for each one just redefines the
15
+ # same reader/writer methods again, which Ruby warns about under -w.
16
+ has_many :counters, as: :countable, dependent: :delete_all unless reflect_on_association(:counters)
13
17
  define_method "#{cache_column}" do
14
18
  # check if the counter is loaded
15
19
  if counters.loaded? && counter = counters.detect{|c| c.key == cache_column.to_s }
@@ -53,16 +57,22 @@ module CustomCounterCache::Model
53
57
  # define callback
54
58
  define_method method_name do
55
59
  # update old association
56
- target_key = reflection.options[:polymorphic] ? "#{association}_type" : foreign_key
57
- if send("saved_change_to_#{target_key}?")
58
- old_id = send("#{target_key}_before_last_save")
59
- klass = if reflection.options[:polymorphic]
60
- ( old_id || send("#{association}_type") ).constantize
61
- else
62
- reflection.klass
60
+ if reflection.options[:polymorphic]
61
+ type_key = "#{association}_type"
62
+ id_key = "#{association}_id"
63
+ if send("saved_change_to_#{id_key}?") || send("saved_change_to_#{type_key}?")
64
+ old_type = send("saved_change_to_#{type_key}?") ? send("#{type_key}_before_last_save") : send(type_key)
65
+ old_id = send("saved_change_to_#{id_key}?") ? send("#{id_key}_before_last_save") : send(id_key)
66
+ if ( old_type && old_id && record = old_type.constantize.find_by(id: old_id) )
67
+ record.send("update_#{cache_column}")
68
+ end
63
69
  end
64
- if ( old_id && record = klass.find_by(id: old_id) )
65
- record.send("update_#{cache_column}")
70
+ else
71
+ if send("saved_change_to_#{foreign_key}?")
72
+ old_id = send("#{foreign_key}_before_last_save")
73
+ if ( old_id && record = reflection.klass.find_by(id: old_id) )
74
+ record.send("update_#{cache_column}")
75
+ end
66
76
  end
67
77
  end
68
78
  # update new association
@@ -1,5 +1,5 @@
1
1
  module CustomCounterCache
2
2
 
3
- VERSION = '0.3.0'
3
+ VERSION = '0.3.1'
4
4
 
5
5
  end
data/test/counter_test.rb CHANGED
@@ -107,4 +107,176 @@ class CounterTest < Minitest::Test
107
107
  assert_equal 1, @user2.reload.published_count
108
108
  end
109
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
+
110
282
  end
data/test/test_helper.rb CHANGED
@@ -39,6 +39,14 @@ ActiveRecord::Schema.define(version: 1) do
39
39
  t.belongs_to :box
40
40
  t.string :color, default: 'red'
41
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
42
50
  end
43
51
 
44
52
  class ApplicationRecord < ActiveRecord::Base
@@ -48,6 +56,7 @@ end
48
56
 
49
57
  class User < ApplicationRecord
50
58
  has_many :articles, dependent: :destroy
59
+ has_one :user_note, dependent: :destroy
51
60
  define_counter_cache :published_count do |user|
52
61
  user.articles.where(articles: { state: 'published' }).count
53
62
  end
@@ -68,7 +77,11 @@ class Comment < ApplicationRecord
68
77
  end
69
78
 
70
79
  class Counter < ApplicationRecord
71
- belongs_to :countable, polymorphic: true
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
72
85
  end
73
86
 
74
87
  class Box < ApplicationRecord
@@ -82,6 +95,15 @@ class Box < ApplicationRecord
82
95
  define_counter_cache :destroyed_balls_count do |box|
83
96
  box.destroyed_balls_count + 1
84
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
85
107
  end
86
108
 
87
109
  class Ball < ApplicationRecord
@@ -90,4 +112,27 @@ class Ball < ApplicationRecord
90
112
  update_counter_cache :box, :green_balls_count, if: Proc.new { |ball| ball.saved_change_to_attribute?(:color) }
91
113
  update_counter_cache :box, :lifetime_balls_count, except: [:update, :destroy]
92
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
93
138
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: custom_counter_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedric Howe
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-01 00:00:00.000000000 Z
11
+ date: 2026-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails