custom_counter_cache 0.3.1 → 0.3.2

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: b86370bc27fe59b1bafc3f2160e69843c9650e58b6c9e980010eb5f88fb229cd
4
- data.tar.gz: 0aeccd374cf039a73f248fa4440f3d472859f73f83b4a0f991b432eaba7bf10e
3
+ metadata.gz: c9162e152f23f2e1bcbe16bc8eac89109fb42e3710f056e280ffc7e2509972ea
4
+ data.tar.gz: 48a92f691a0d60be81222f930ee511cf7cc5af62b90a2d6dbb940d2bd6c40e83
5
5
  SHA512:
6
- metadata.gz: f0de231b5e888db121d43d3a37909e37b8e3cb1fd4bf0c58ad0195bda8030f4937a1aba70729716c6c23f3ec1aa48888f68308bb998d6e50fe8bcb71529d9bde
7
- data.tar.gz: 721794ae2b6cb35349209392f390d42352d3f0fa31a10599b7c13e7dd98e00bdba7c29c96ccfd9033d99e4c508aeab845ec2db9e653bd93f21238fb899892d12
6
+ metadata.gz: a5140b21c810447e0f8593b57252ef42064cecfc26168a46df39fa2f813f042e54aabb32c602f275c6ead13f30fc4c05b929db0cb2ffbb557367be1a4b628ef9
7
+ data.tar.gz: 8a0101a516d27d8b076d97173fa1733b6d4b53f85877448229c30afaa60ea78bb7059fda11e41602de505148497f79a36622bb394f968340f8947fc788b68725
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.3.2
4
+
5
+ ### Fixed
6
+
7
+ - Moving a record between owners now recounts the old owner when `belongs_to` uses a custom
8
+ `primary_key:`.
9
+ - Polymorphic `belongs_to` with custom `foreign_key:`/`foreign_type:` no longer raises
10
+ `NoMethodError` on save.
11
+ - Moving a record away from a polymorphic type whose class no longer exists no longer raises
12
+ `NameError`; the new owner is still recounted.
13
+ - With `includes(:counters)`, counters stay current after an update, and a missing counter
14
+ returns 0 without a query.
15
+ - Two saves creating the same virtual counter at once no longer fail with
16
+ `ActiveRecord::RecordNotUnique`; the second updates the row instead.
17
+ - `update_counter_cache` without its `belongs_to` raises a clear `ArgumentError` instead of
18
+ `NoMethodError ... for nil`.
19
+
20
+ ### Changed
21
+
22
+ - Depend on `activerecord` and `activesupport` instead of all of `rails`.
23
+ - Require Ruby >= 3.1 in the gemspec, matching the README and Rails 7.2.
24
+ - Test against every non-EOL Ruby (3.3, 3.4, 4.0) and Rails (8.0, 8.1).
25
+ - Expand the test suite to cover all branches.
26
+ - README: fix the `:if` example (`state_changed?` is always false in after callbacks)
27
+ and note that `:if` also applies to `after_destroy`.
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2011-2026 Cedric Howe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,87 @@
1
+ {rdoc-image:https://github.com/cedric/custom_counter_cache/actions/workflows/ci.yml/badge.svg}[https://github.com/cedric/custom_counter_cache/actions/workflows/ci.yml]
2
+
3
+ == Custom Counter Cache
4
+
5
+ This is a simple approach to creating a custom counter cache in Rails that can be used across multiple models.
6
+
7
+ === Requirements
8
+
9
+ * Ruby >= 3.1
10
+ * Rails (Active Record) >= 7.2
11
+
12
+ CI covers the Ruby and Rails versions that are not yet end-of-life.
13
+
14
+ === Installation
15
+
16
+ Add the following to your Gemfile:
17
+
18
+ gem 'custom_counter_cache'
19
+
20
+ === Example
21
+
22
+ == Class with counter cache
23
+
24
+ This is the block that will be used to calculate the value for the counter cache. It will be called by other models through their association via an after_save or after_destroy callback.
25
+
26
+ include CustomCounterCache::Model
27
+ define_counter_cache :articles_count do |user|
28
+ user.articles.where(state: 'published').count
29
+ end
30
+
31
+ == Class with callbacks
32
+
33
+ This will define the after_create, after_update and after_destroy callbacks. An :if option can be provided to limit when these callbacks get triggered.
34
+
35
+ include CustomCounterCache::Model
36
+ update_counter_cache :user, :articles_count, if: -> (article) { article.destroyed? || article.saved_change_to_state? }
37
+
38
+ The callbacks run after the record is saved, so use <tt>saved_change_to_state?</tt> rather than
39
+ <tt>state_changed?</tt> (which is always false by then). The :if condition also applies to
40
+ after_destroy, where there are no saved changes, hence the <tt>destroyed?</tt> check.
41
+
42
+ Other options: <tt>:unless</tt>, <tt>:only</tt> / <tt>:except</tt> (e.g. <tt>only: [:create, :destroy]</tt>)
43
+ and <tt>:prepend</tt>.
44
+
45
+ These callbacks can be added to any number of models that might need to change the counter cache.
46
+
47
+ == Counter Cache
48
+
49
+ To store the counter cache you need to create a column for the model with the counter cache (example: articles_count).
50
+
51
+ If you would like to store all of your counter caches in a single table, you can use this migration:
52
+
53
+ create_table :counters do |t|
54
+ t.references :countable, polymorphic: true
55
+ t.string :key, null: false
56
+ t.integer :value, null: false, default: 0
57
+ t.timestamps
58
+ end
59
+ add_index :counters, [ :countable_id, :countable_type, :key ], unique: true
60
+
61
+ Here is the example model to go with:
62
+
63
+ class Counter < ActiveRecord::Base
64
+ belongs_to :countable, polymorphic: true
65
+ validates :countable, presence: true
66
+ end
67
+
68
+ Note: do not add <tt>dependent: :destroy</tt> to that +belongs_to+. On a
69
+ +belongs_to+, <tt>dependent: :destroy</tt> means "when this Counter is
70
+ destroyed, also destroy the record it belongs to" -- the opposite of what you
71
+ want, and it creates a destroy loop: destroying a countable record cascades
72
+ into destroying its Counters (via the <tt>has_many :counters, dependent:
73
+ :delete_all</tt> this gem defines), and a wrongly-configured +belongs_to+ would
74
+ then cascade back into destroying the same countable record again. The
75
+ countable side's <tt>dependent: :delete_all</tt> already handles cleanup and
76
+ skips Counter's own callbacks entirely, so no +dependent+ option belongs on
77
+ the +belongs_to+ side at all.
78
+
79
+ If you would like to store your counter cache in an existing table, you can use this migration:
80
+
81
+ def change
82
+ add_column :users, :articles_count, :integer, default: 0, null: false
83
+ end
84
+
85
+ To backfill your counters, run something like this either in a migration or in the console:
86
+
87
+ User.select(:id).find_each(batch_size: 100) { |u| u.update_articles_count }
@@ -9,24 +9,30 @@ module CustomCounterCache::Model
9
9
 
10
10
  # counter accessors
11
11
  unless column_names.include?(cache_column.to_s)
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.
12
+ # Declare once per class: redeclaring for each virtual counter triggers method-redefinition warnings.
16
13
  has_many :counters, as: :countable, dependent: :delete_all unless reflect_on_association(:counters)
17
14
  define_method "#{cache_column}" do
18
- # check if the counter is loaded
19
- if counters.loaded? && counter = counters.detect{|c| c.key == cache_column.to_s }
20
- counter.value
15
+ # Once loaded (e.g. includes(:counters)), a missing row means 0, not a query.
16
+ if counters.loaded?
17
+ counters.detect { |c| c.key == cache_column.to_s }.try(:value).to_i
21
18
  else
22
19
  counters.find_by(key: cache_column.to_s).try(:value).to_i
23
20
  end
24
21
  end
25
22
  define_method "#{cache_column}=" do |count|
26
- if ( counter = counters.find_by(key: cache_column.to_s) )
23
+ # Update the loaded Counter itself, or the reader keeps returning its stale value.
24
+ counter = counters.loaded? ? counters.detect { |c| c.key == cache_column.to_s } : counters.find_by(key: cache_column.to_s)
25
+ if counter
27
26
  counter.update_attribute :value, count.to_i
28
27
  else
29
- counters.create key: cache_column.to_s, value: count.to_i
28
+ begin
29
+ # Savepoint: on PostgreSQL a failed INSERT would otherwise abort the caller's whole transaction.
30
+ self.class.transaction(requires_new: true) { counters.create key: cache_column.to_s, value: count.to_i }
31
+ rescue ActiveRecord::RecordNotUnique
32
+ # Lost a create race. A locking read sees the winner's row even under REPEATABLE READ (MySQL).
33
+ counters.reset
34
+ counters.lock.find_by!(key: cache_column.to_s).update_attribute :value, count.to_i
35
+ end
30
36
  end
31
37
  end
32
38
  end
@@ -52,25 +58,28 @@ module CustomCounterCache::Model
52
58
  cache_column = cache_column.to_sym
53
59
  method_name = "callback_#{association}_#{cache_column}".to_sym
54
60
  reflection = reflect_on_association(association)
55
- foreign_key = reflection.try(:foreign_key) || reflection.association_foreign_key
61
+ raise ArgumentError, "#{self} must declare belongs_to :#{association} before update_counter_cache" unless reflection
62
+ foreign_key = reflection.foreign_key
56
63
 
57
64
  # define callback
58
65
  define_method method_name do
59
66
  # update old association
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
+ if reflection.polymorphic?
68
+ type_key = reflection.foreign_type
69
+ id_key = foreign_key
70
+ if saved_change_to_attribute?(id_key) || saved_change_to_attribute?(type_key)
71
+ old_type = saved_change_to_attribute?(type_key) ? attribute_before_last_save(type_key) : self[type_key]
72
+ old_id = saved_change_to_attribute?(id_key) ? attribute_before_last_save(id_key) : self[id_key]
73
+ # safe_: the stored type may name a class that has since been renamed or removed.
74
+ old_klass = old_type&.safe_constantize
75
+ if ( old_klass && old_id && record = old_klass.find_by(reflection.association_primary_key(old_klass) => old_id) )
67
76
  record.send("update_#{cache_column}")
68
77
  end
69
78
  end
70
79
  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) )
80
+ if saved_change_to_attribute?(foreign_key)
81
+ old_id = attribute_before_last_save(foreign_key)
82
+ if ( old_id && record = reflection.klass.find_by(reflection.association_primary_key => old_id) )
74
83
  record.send("update_#{cache_column}")
75
84
  end
76
85
  end
@@ -1,5 +1,5 @@
1
1
  module CustomCounterCache
2
2
 
3
- VERSION = '0.3.1'
3
+ VERSION = '0.3.2'
4
4
 
5
5
  end
metadata CHANGED
@@ -1,17 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: custom_counter_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedric Howe
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-07-30 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: rails
13
+ name: activerecord
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
@@ -30,6 +29,60 @@ dependencies:
30
29
  - - "<"
31
30
  - !ruby/object:Gem::Version
32
31
  version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activesupport
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.2'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.2'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: minitest
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '5.0'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '7'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '5.0'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '7'
72
+ - !ruby/object:Gem::Dependency
73
+ name: rake
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - "~>"
77
+ - !ruby/object:Gem::Version
78
+ version: '13.0'
79
+ type: :development
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - "~>"
84
+ - !ruby/object:Gem::Version
85
+ version: '13.0'
33
86
  - !ruby/object:Gem::Dependency
34
87
  name: sqlite3
35
88
  requirement: !ruby/object:Gem::Requirement
@@ -44,22 +97,26 @@ dependencies:
44
97
  - - "~>"
45
98
  - !ruby/object:Gem::Version
46
99
  version: '2.0'
47
- description: ''
100
+ description: Define counter caches computed by an arbitrary block (e.g. a scoped count),
101
+ refreshed by callbacks on any number of associated models, and stored in a column
102
+ or a shared polymorphic counters table.
48
103
  email: cedric@howe.net
49
104
  executables: []
50
105
  extensions: []
51
106
  extra_rdoc_files: []
52
107
  files:
108
+ - CHANGELOG.md
109
+ - LICENSE
110
+ - README.rdoc
53
111
  - lib/custom_counter_cache.rb
54
112
  - lib/custom_counter_cache/model.rb
55
113
  - lib/custom_counter_cache/version.rb
56
- - test/counter_test.rb
57
- - test/test_helper.rb
58
- homepage: http://github.com/cedric/custom_counter_cache/
114
+ homepage: https://github.com/cedric/custom_counter_cache
59
115
  licenses:
60
116
  - MIT
61
- metadata: {}
62
- post_install_message:
117
+ metadata:
118
+ source_code_uri: https://github.com/cedric/custom_counter_cache
119
+ changelog_uri: https://github.com/cedric/custom_counter_cache/blob/main/CHANGELOG.md
63
120
  rdoc_options: []
64
121
  require_paths:
65
122
  - lib
@@ -67,18 +124,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
124
  requirements:
68
125
  - - ">="
69
126
  - !ruby/object:Gem::Version
70
- version: '3.0'
127
+ version: '3.1'
71
128
  required_rubygems_version: !ruby/object:Gem::Requirement
72
129
  requirements:
73
130
  - - ">="
74
131
  - !ruby/object:Gem::Version
75
- version: 1.3.6
132
+ version: '0'
76
133
  requirements: []
77
- rubygems_version: 3.5.22
78
- signing_key:
134
+ rubygems_version: 4.0.21
79
135
  specification_version: 4
80
136
  summary: Custom counter_cache functionality that supports conditions and multiple
81
137
  models.
82
- test_files:
83
- - test/counter_test.rb
84
- - test/test_helper.rb
138
+ test_files: []
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