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 +4 -4
- data/lib/custom_counter_cache/model.rb +20 -10
- data/lib/custom_counter_cache/version.rb +1 -1
- data/test/counter_test.rb +172 -0
- data/test/test_helper.rb +46 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b86370bc27fe59b1bafc3f2160e69843c9650e58b6c9e980010eb5f88fb229cd
|
|
4
|
+
data.tar.gz: 0aeccd374cf039a73f248fa4440f3d472859f73f83b4a0f991b432eaba7bf10e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
(
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
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
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-07-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|