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 +4 -4
- data/CHANGELOG.md +27 -0
- data/LICENSE +9 -0
- data/README.rdoc +87 -0
- data/lib/custom_counter_cache/model.rb +29 -20
- data/lib/custom_counter_cache/version.rb +1 -1
- metadata +71 -17
- data/test/counter_test.rb +0 -282
- data/test/test_helper.rb +0 -138
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9162e152f23f2e1bcbe16bc8eac89109fb42e3710f056e280ffc7e2509972ea
|
|
4
|
+
data.tar.gz: 48a92f691a0d60be81222f930ee511cf7cc5af62b90a2d6dbb940d2bd6c40e83
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
#
|
|
19
|
-
if counters.loaded?
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
61
|
-
type_key =
|
|
62
|
-
id_key =
|
|
63
|
-
if
|
|
64
|
-
old_type =
|
|
65
|
-
old_id =
|
|
66
|
-
|
|
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
|
|
72
|
-
old_id =
|
|
73
|
-
if ( old_id && record = reflection.klass.find_by(
|
|
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
|
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.
|
|
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:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
132
|
+
version: '0'
|
|
76
133
|
requirements: []
|
|
77
|
-
rubygems_version:
|
|
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
|