custom_counter_cache 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9162e152f23f2e1bcbe16bc8eac89109fb42e3710f056e280ffc7e2509972ea
4
- data.tar.gz: 48a92f691a0d60be81222f930ee511cf7cc5af62b90a2d6dbb940d2bd6c40e83
3
+ metadata.gz: 56544a2488e48dc35c3ad64e7eb7cb0bc2c0be1bc3f5aa8ee5a52a349de2fcb8
4
+ data.tar.gz: 5d40812f87a2d9ce720e77558ac1cec17c81a607170d2a9e690b56bcc9481e29
5
5
  SHA512:
6
- metadata.gz: a5140b21c810447e0f8593b57252ef42064cecfc26168a46df39fa2f813f042e54aabb32c602f275c6ead13f30fc4c05b929db0cb2ffbb557367be1a4b628ef9
7
- data.tar.gz: 8a0101a516d27d8b076d97173fa1733b6d4b53f85877448229c30afaa60ea78bb7059fda11e41602de505148497f79a36622bb394f968340f8947fc788b68725
6
+ metadata.gz: 287db2bc86cca62e838cc32849eb1bb43fe66f9d40e771981bb56df221617e6bdf4392950808cafa031afb8a90fe9682ca68053bc0867ad5df802a76ca47f073
7
+ data.tar.gz: e096528b08c44673508784b4faa7bdaa500d5f5f30b5844d4d2a68a1dcf77ff241df30d4805ad58eb22f126f6fb821fc9214c5114fdf4ee520eca44e15ee3981
data/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Breaking
6
+
7
+ - Requires Ruby >= 3.3 and Rails (Active Record) >= 8.0.
8
+ - `:if`/`:unless` on `update_counter_cache` now only gate the create and update callbacks; a
9
+ destroy always recounts. Use `except: [:destroy]` to skip it.
10
+ - Column counters are written with `update_column`: recounts no longer save other unsaved
11
+ changes on the owner, touch its `updated_at`, run its callbacks or bump `lock_version`.
12
+ - `update_counter_cache` raises `ArgumentError` for associations other than `belongs_to`
13
+ (they have never worked).
14
+ - Every model that calls `define_counter_cache` gets the `counters` association, not only models
15
+ with a counter that has no column.
16
+ - Defining counters on a table that doesn't exist no longer silently defines nothing.
17
+ - Recounts triggered by `update_counter_cache` callbacks run after the saving transaction commits
18
+ instead of inside it: once per owner and counter per transaction, not at all on rollback, and
19
+ under a short lock on the owner's row. Counting inside the transaction couldn't see a concurrent
20
+ save's uncommitted child, so concurrent saves to the same owner left a stale count (on
21
+ PostgreSQL, 399 of 400 concurrent test runs). The counter now changes at commit rather than at
22
+ save; call `update_<name>` directly to recount immediately inside a transaction. Rails'
23
+ transactional tests commit each save within the test transaction, so tests see the recount.
24
+ - A counter stored in the counters table raises `ArgumentError` for a value that isn't a whole
25
+ number (e.g. `2.5`, `'many'`, infinity) instead of silently truncating it with `to_i`. `nil`
26
+ still means 0, and whole values like `4.0` or `BigDecimal('4')` are still accepted. Use a column
27
+ of a suitable type or `store: :cache` for fractional values.
28
+
29
+ ### Changed
30
+
31
+ - Whether a counter uses a column or the counters table is decided when it's read or written, so
32
+ loading models never queries the database, and a column added after the model has loaded is
33
+ picked up. The Heroku `DATABASE_URL` workaround is gone.
34
+ - Counter rows are deleted on destroy only when the model has a counter without a column, so
35
+ column-only models never touch the counters table.
36
+ - A recount that raises after commit is passed to `ActiveSupport.error_reporter.unexpected` (with
37
+ the owner and counter as context) and logged, instead of propagating: the save has already
38
+ committed, and one failure no longer skips the other recounts queued for that commit. In
39
+ development and test Rails' debug mode still raises it. Direct `update_<name>` calls,
40
+ `recount_counter_caches` and `CustomCounterCache::RecountJob` still raise.
41
+ - Paranoia's `restore` now recounts the owner for callbacks declared after `acts_as_paranoid`; it
42
+ didn't before. Add `except: [:restore]` to keep the old behavior.
43
+
44
+ ### Added
45
+
46
+ - `CustomCounterCache.counter_class_name` (default `'Counter'`) sets the model behind the
47
+ counters table.
48
+ - `define_counter_cache :name, touch: true` (or `touch: :column`) sets a timestamp on the owner
49
+ whenever the counter is recounted. A column counter is written in the same UPDATE, and no
50
+ callbacks run.
51
+ - `define_counter_cache :name, store: :cache, expires_in:` keeps a counter in
52
+ `CustomCounterCache.cache_store` (defaults to `Rails.cache`). It is computed on a miss and
53
+ deleted after commit when a child changes, instead of being recounted. Destroying the owner
54
+ deletes its keys.
55
+ - `update_counter_cache ..., on_change: [:attr]` recounts on update only when a listed attribute
56
+ or the association's key columns changed. Create and destroy always recount.
57
+ - `update_counter_cache [:article, :user], :comments_count` follows a path of `belongs_to`
58
+ associations and recounts both the old and the new owner at the end of it (grandparent counters).
59
+ - `update_counter_cache ..., recount: :later` recounts in `CustomCounterCache::RecountJob`
60
+ (Active Job), enqueued after commit, once per owner and counter per transaction.
61
+ - `CustomCounterCache.batch { }` recounts each owner and counter once after the block ends.
62
+ - `CustomCounterCache.skip { }` drops recounts inside the block.
63
+ - `Model.recount_counter_caches(*names, scope:, batch_size:)` recounts every record (or a scope) in
64
+ batches, repairing drift from `update_all`, `delete_all` and imports.
65
+ - The `custom_counter_cache:recount` rake task (`MODEL=`, `COUNTERS=`, `BATCH_SIZE=`) wraps
66
+ `recount_counter_caches`.
67
+ - Composite primary keys: owners and `belongs_to` associations with composite keys work with
68
+ column and cache counters, reassignment (including a change to only some key columns),
69
+ `on_change:`, `recount: :later` and `recount_counter_caches`. The counters table can't hold a
70
+ composite key, so a counter there raises `ArgumentError` naming the alternatives.
71
+ - `only:` and `except:` on `update_counter_cache` accept `:restore`, the event fired by Paranoia's
72
+ `restore`.
73
+
74
+ ### Upgrading
75
+
76
+ - If an `:if`/`:unless` condition was meant to skip destroys, add `except: [:destroy]`.
77
+ - If you relied on a recount updating the owner's `updated_at`, pass `touch: true` to
78
+ `define_counter_cache`. Owner callbacks no longer run on a recount; call them explicitly if needed.
79
+ - If a migration adds a counter column and backfills it, call `reset_column_information` on the
80
+ model before backfilling.
81
+ - Counters now change when the saving transaction commits. Code that saves a child and reads the
82
+ owner's counter inside the same transaction should call `update_<name>` first.
83
+ - A counters-table counter whose block can return a fractional value (an average, a sum of
84
+ decimals) now raises: move it to a column of a suitable type or to `store: :cache`.
85
+ - On a model with a string or UUID primary key, the counters table's `countable_id` must be that
86
+ type (`t.references :countable, polymorphic: true, type: :uuid`).
87
+
3
88
  ## 0.3.2
4
89
 
5
90
  ### Fixed
data/README.rdoc CHANGED
@@ -6,8 +6,8 @@ This is a simple approach to creating a custom counter cache in Rails that can b
6
6
 
7
7
  === Requirements
8
8
 
9
- * Ruby >= 3.1
10
- * Rails (Active Record) >= 7.2
9
+ * Ruby >= 3.3
10
+ * Rails (Active Record) >= 8.0
11
11
 
12
12
  CI covers the Ruby and Rails versions that are not yet end-of-life.
13
13
 
@@ -17,38 +17,180 @@ Add the following to your Gemfile:
17
17
 
18
18
  gem 'custom_counter_cache'
19
19
 
20
- === Example
20
+ == Defining a counter
21
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.
22
+ The block calculates the counter's value from its owner. It is called whenever a model that
23
+ declares +update_counter_cache+ (see below) changes.
25
24
 
26
25
  include CustomCounterCache::Model
27
26
  define_counter_cache :articles_count do |user|
28
27
  user.articles.where(state: 'published').count
29
28
  end
30
29
 
31
- == Class with callbacks
30
+ Pass <tt>touch: true</tt> (or a column name, e.g. <tt>touch: :counted_at</tt>) to also set that
31
+ timestamp, <tt>updated_at</tt> for +true+, on every recount. A column counter is written in the same
32
+ UPDATE. No callbacks run.
33
+
34
+ Where the value is kept depends on the counter: a column of the same name, a row in a shared
35
+ counters table, or a cache store (<tt>store: :cache</tt>). See Choosing storage below.
32
36
 
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.
37
+ == Triggering recounts
38
+
39
+ Declare which models change the counter with +update_counter_cache+, naming a +belongs_to+
40
+ association and the counter on its owner. It defines the after_create, after_update and
41
+ after_destroy callbacks (plus after_restore for Paranoia, see Soft deletes).
34
42
 
35
43
  include CustomCounterCache::Model
36
- update_counter_cache :user, :articles_count, if: -> (article) { article.destroyed? || article.saved_change_to_state? }
44
+ update_counter_cache :user, :articles_count, if: -> (article) { article.saved_change_to_state? }
45
+
46
+ These callbacks can be added to any number of models that might need to change the counter.
47
+ When a record moves to another owner, both the old and the new owner are recounted.
48
+
49
+ Options:
50
+
51
+ * <tt>:if</tt>, <tt>:unless</tt>: limit when the create and update callbacks run. The callbacks
52
+ run after the record is saved, so use <tt>saved_change_to_state?</tt> rather than
53
+ <tt>state_changed?</tt>, which is always false by then. They don't apply to destroy or restore:
54
+ those have no saved changes, and an extra recount is never wrong.
55
+ * <tt>on_change:</tt>: an attribute or an array. Recount on update only when one of them changed;
56
+ prefer it to +:if+ for plain attribute checks. Create and destroy always recount. It combines
57
+ with +:if+ and +:unless+ (both must allow the update).
58
+ * <tt>only:</tt>, <tt>except:</tt>: limit the events, any of <tt>:create</tt>, <tt>:update</tt>,
59
+ <tt>:destroy</tt> and <tt>:restore</tt>, e.g. <tt>only: [:create, :destroy]</tt> or
60
+ <tt>except: [:destroy]</tt>.
61
+ * <tt>:prepend</tt>: prepend the callbacks instead of appending them.
62
+ * <tt>recount: :later</tt>: recount in a background job instead of right after commit. See
63
+ When recounts run.
64
+
65
+ For example, to recount only when the state changes:
66
+
67
+ update_counter_cache :user, :articles_count, on_change: [:state]
68
+
69
+ The association's key columns (plus the type column for a polymorphic one) are always watched
70
+ by +on_change+, so moving the record to another owner recounts both owners.
71
+
72
+ == Grandparent counters
73
+
74
+ Pass a path of +belongs_to+ associations to recount an owner further up. For a
75
+ <tt>comments_count</tt> on User across all of a user's articles:
76
+
77
+ class User < ApplicationRecord
78
+ include CustomCounterCache::Model
79
+ has_many :articles
80
+ has_many :comments, through: :articles
81
+ define_counter_cache(:comments_count) { |user| user.comments.count }
82
+ end
83
+
84
+ class Comment < ApplicationRecord
85
+ include CustomCounterCache::Model
86
+ belongs_to :article
87
+ update_counter_cache [:article, :user], :comments_count
88
+ end
89
+
90
+ class Article < ApplicationRecord
91
+ include CustomCounterCache::Model
92
+ belongs_to :user
93
+ update_counter_cache :user, :comments_count # an article moving users changes both counts
94
+ end
95
+
96
+ Moving a comment recounts the old and the new user, once if they're the same user. The second
97
+ declaration is needed because an Article changing user also changes both users' counts, and only
98
+ Article's own callbacks see that. A step after a polymorphic one is skipped for records whose
99
+ class has no such association; any other missing or non-+belongs_to+ step raises ArgumentError.
100
+
101
+ == Soft deletes
102
+
103
+ Discard soft-deletes with an ordinary update, so the callbacks already fire: filter with
104
+ <tt>.kept</tt> in the counter block, and if you use <tt>on_change:</tt> include
105
+ <tt>:discarded_at</tt> in it, or discarding won't recount.
106
+
107
+ define_counter_cache(:pages_count) { |notebook| notebook.pages.kept.count }
108
+ update_counter_cache :notebook, :pages_count, on_change: [:state, :discarded_at]
109
+
110
+ Paranoia's +destroy+ runs the destroy callbacks, and +restore+ is recounted too, provided
111
+ +acts_as_paranoid+ is declared before +update_counter_cache+. Skip it with <tt>except: [:restore]</tt>
112
+ (<tt>:restore</tt> is also accepted by <tt>only:</tt>).
113
+
114
+ class Parcel < ApplicationRecord
115
+ acts_as_paranoid
116
+ belongs_to :crate
117
+ update_counter_cache :crate, :parcels_count
118
+ end
119
+
120
+ == Batching and skipping
121
+
122
+ Every triggering save recounts its owner. For bulk work, batch the recounts so each owner and
123
+ counter is recounted once, when the block ends (or when its transaction commits):
124
+
125
+ CustomCounterCache.batch do
126
+ rows.each { |row| article.comments.create!(row) } # one recount of article.comments_count
127
+ end
128
+
129
+ Batches nest (the outermost one flushes) and still flush if the block raises, since a recount
130
+ reflects whatever is in the database. To skip recounts entirely, e.g. for an import you'll
131
+ recount afterwards:
132
+
133
+ CustomCounterCache.skip { import_comments }
37
134
 
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.
135
+ Both are per thread (and per fiber).
41
136
 
42
- Other options: <tt>:unless</tt>, <tt>:only</tt> / <tt>:except</tt> (e.g. <tt>only: [:create, :destroy]</tt>)
43
- and <tt>:prepend</tt>.
137
+ == When recounts run
44
138
 
45
- These callbacks can be added to any number of models that might need to change the counter cache.
139
+ Recounts triggered by these callbacks run after the saving transaction commits, once per owner
140
+ and counter per transaction, and not at all if it rolls back. Each takes a short lock on the
141
+ owner's row while it counts, so two saves committing at once can't leave a stale value: the
142
+ later recount always counts after the earlier commit. (Counting inside the saving transaction
143
+ can't see a concurrent save's uncommitted child, so one of them would overwrite the other.)
46
144
 
47
- == Counter Cache
145
+ The counter therefore changes when the transaction commits, not when the child is saved. Inside
146
+ a transaction, call the update method yourself if you need the new value straight away:
48
147
 
49
- To store the counter cache you need to create a column for the model with the counter cache (example: articles_count).
148
+ Article.transaction do
149
+ user.articles.create!(attrs)
150
+ user.update_articles_count # recounts now; update_* and recount_counter_caches never wait
151
+ end
152
+
153
+ If a recount raises after commit, the save has already succeeded and the count can be rebuilt, so
154
+ the error doesn't propagate to the caller or stop the other recounts queued for that commit. It's
155
+ passed to <tt>Rails.error.unexpected</tt>, which raises in development and test (with
156
+ <tt>consider_all_requests_local</tt>, as by default) and in production reports it to your error
157
+ tracker with the owner and counter in the context. It's also logged. Calling
158
+ <tt>update_<name></tt> or <tt>recount_counter_caches</tt> yourself raises as usual, and so does
159
+ <tt>CustomCounterCache::RecountJob</tt>, so your queue's retries apply.
160
+
161
+ To recount in a background job instead, enqueued after commit:
162
+
163
+ update_counter_cache :user, :articles_count, recount: :later # CustomCounterCache::RecountJob (Active Job)
164
+
165
+ To pick a queue, <tt>CustomCounterCache::RecountJob.queue_as :low</tt>. Rails' transactional
166
+ tests commit each save within the test transaction, so recounts happen there as they do in
167
+ production.
168
+
169
+ == Choosing storage
170
+
171
+ [Column] Add a column with the counter's name. Fastest to read, and you can sort and
172
+ filter by it in SQL. Needs a migration per counter.
173
+ [Counters table] Used automatically when there's no column. One shared table, no migration per
174
+ counter; preload with <tt>includes(:counters)</tt>. See The counters table.
175
+ [Cache] <tt>define_counter_cache :x, store: :cache, expires_in: 12.hours</tt>. Kept in
176
+ <tt>CustomCounterCache.cache_store</tt> (defaults to Rails.cache: Redis,
177
+ Memcached, Solid Cache...). Reads compute on a miss; a child change deletes the
178
+ key after commit instead of recounting, so writes are cheap and never lock the
179
+ owner's row. Destroying the owner deletes its keys. Not visible to SQL, and a
180
+ delete racing a concurrent read can leave a stale value for up to +expires_in+.
181
+
182
+ Column or counters table is decided when the counter is read or written, not when the model
183
+ loads, so defining counters never touches the database, and a column added later is picked up.
50
184
 
51
- If you would like to store all of your counter caches in a single table, you can use this migration:
185
+ To use a column, add one:
186
+
187
+ def change
188
+ add_column :users, :articles_count, :integer, default: 0, null: false
189
+ end
190
+
191
+ == The counters table
192
+
193
+ To store counters in a single shared table instead, use this migration:
52
194
 
53
195
  create_table :counters do |t|
54
196
  t.references :countable, polymorphic: true
@@ -58,6 +200,17 @@ If you would like to store all of your counter caches in a single table, you can
58
200
  end
59
201
  add_index :counters, [ :countable_id, :countable_type, :key ], unique: true
60
202
 
203
+ The table holds whole numbers. A block result of +nil+ is stored as 0, and whole values such as
204
+ <tt>4.0</tt> or <tt>BigDecimal('4')</tt> are converted, but a fractional result such as an average
205
+ raises ArgumentError rather than being truncated: give that counter a column of a suitable type
206
+ (e.g. decimal) or use <tt>store: :cache</tt>.
207
+
208
+ +countable_id+ must match your models' primary key type: for string or UUID keys, use
209
+ <tt>t.references :countable, polymorphic: true, type: :uuid</tt> (or <tt>:string</tt>). A model
210
+ with a composite primary key can't use this table, since +countable_id+ holds one value: give its
211
+ counters a column or <tt>store: :cache</tt> (it raises ArgumentError otherwise). Composite keys
212
+ work everywhere else, including composite foreign keys on the +belongs_to+ side.
213
+
61
214
  Here is the example model to go with:
62
215
 
63
216
  class Counter < ActiveRecord::Base
@@ -65,23 +218,44 @@ Here is the example model to go with:
65
218
  validates :countable, presence: true
66
219
  end
67
220
 
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.
221
+ To use a different model name (for example if +Counter+ is already taken), set
222
+ <tt>CustomCounterCache.counter_class_name</tt> (see Configuration).
78
223
 
79
- If you would like to store your counter cache in an existing table, you can use this migration:
224
+ When a record is destroyed, its counter rows are removed with a single DELETE, without loading
225
+ them or running Counter's callbacks. Do not add <tt>dependent: :destroy</tt> to the +belongs_to+
226
+ above: on a +belongs_to+ it means "destroying this Counter also destroys its owner".
80
227
 
81
- def change
82
- add_column :users, :articles_count, :integer, default: 0, null: false
83
- end
228
+ == Backfilling and repairing
229
+
230
+ To backfill your counters, or repair them later, recount every record from the console or a migration:
231
+
232
+ User.recount_counter_caches
233
+
234
+ or only some counters and records:
235
+
236
+ User.recount_counter_caches(:articles_count, scope: User.where(id: 1..1000), batch_size: 500)
237
+
238
+ It returns the number of records processed. The same is available as a rake task:
239
+
240
+ bin/rails custom_counter_cache:recount MODEL=User COUNTERS=articles_count
241
+
242
+ +COUNTERS+ (comma-separated, default all) and +BATCH_SIZE+ (default 1000) are optional.
243
+
244
+ Callbacks can't see +update_all+, +delete_all+, +insert_all+ or SQL imports, so counts drift after
245
+ them. Run the recount periodically, or after an import, to repair that. It calls +update_<name>+
246
+ directly, so it also works inside <tt>CustomCounterCache.skip { }</tt>.
247
+
248
+ In a migration that also adds the column, call <tt>User.reset_column_information</tt> first so the
249
+ backfill writes to the new column.
250
+
251
+ == Configuration
252
+
253
+ Set these in an initializer (<tt>config/initializers/custom_counter_cache.rb</tt>):
84
254
 
85
- To backfill your counters, run something like this either in a migration or in the console:
255
+ * <tt>CustomCounterCache.counter_class_name</tt>: the model behind the counters table. Default
256
+ <tt>'Counter'</tt>. Must be set before your models load.
257
+ * <tt>CustomCounterCache.cache_store</tt>: the store for <tt>store: :cache</tt> counters. Defaults
258
+ to Rails.cache.
86
259
 
87
- User.select(:id).find_each(batch_size: 100) { |u| u.update_articles_count }
260
+ CustomCounterCache.counter_class_name = 'CounterCache'
261
+ CustomCounterCache.cache_store = ActiveSupport::Cache::MemoryStore.new
@@ -0,0 +1,101 @@
1
+ require 'active_support/isolated_execution_state'
2
+
3
+ module CustomCounterCache
4
+ # Recounts each owner and counter once after the outermost batch ends (after commit, if inside a transaction).
5
+ def self.batch(&block)
6
+ Dispatcher.batch(&block)
7
+ end
8
+
9
+ # Drops recounts inside the block, e.g. for an import followed by recount_counter_caches.
10
+ def self.skip(&block)
11
+ Dispatcher.skip(&block)
12
+ end
13
+
14
+ # Single path from update_counter_cache's callbacks to an owner's recount.
15
+ module Dispatcher
16
+ # Always after commit: inside the saving transaction a recount can't see a concurrent save's
17
+ # uncommitted child, and the later of the two overwrites the count with a stale one.
18
+ def self.recount(owner, name, timing = :after_commit)
19
+ return if state[:skip]
20
+ # A cached counter is invalidated after commit, never recounted in place.
21
+ timing = :invalidate if owner.class.custom_counter_cache_storage(name) == :cache
22
+ # Keep the first instance seen, so loaded counters on it stay current.
23
+ return state[:batch][[owner.class, owner.id, name]] ||= [owner, timing] if state[:batch]
24
+ defer(owner, name, timing)
25
+ end
26
+
27
+ # Serializes recounts of one owner on its row, so each counts after any concurrent commit.
28
+ # Locks by query rather than with_lock, which would reload the owner and drop its unsaved changes.
29
+ def self.locked_recount(owner, name)
30
+ klass = owner.class
31
+ klass.transaction do
32
+ # Owner deleted meanwhile: nothing left to recount.
33
+ next unless klass.where(CustomCounterCache.key_conditions(klass.primary_key, owner.id)).lock.pick(*Array(klass.primary_key).first(1))
34
+ owner.public_send("update_#{name}")
35
+ end
36
+ end
37
+
38
+ def self.batch
39
+ outermost = state[:batch].nil?
40
+ state[:batch] ||= {}
41
+ yield
42
+ ensure
43
+ # A recount reflects whatever is in the database, so flushing after an exception is still correct.
44
+ if outermost
45
+ pending = state.delete(:batch)
46
+ pending.each { |(_, _, name), (owner, timing)| recount(owner, name, timing) }
47
+ end
48
+ end
49
+
50
+ def self.skip
51
+ skipping = state[:skip]
52
+ state[:skip] = true
53
+ yield
54
+ ensure
55
+ state[:skip] = skipping
56
+ end
57
+
58
+ # Once per owner and counter per transaction. A recount reads committed state, so running it late is still correct.
59
+ def self.defer(owner, name, timing)
60
+ key = [owner.class, owner.id, name, timing]
61
+ deferred = state[:deferred] ||= {}
62
+ return if deferred.key?(key)
63
+
64
+ transaction = owner.class.current_transaction
65
+ deferred[key] = true if transaction.open?
66
+ # A rolled-back savepoint drops its after_commit, so forget the key and let a later save schedule it again.
67
+ transaction.after_rollback { deferred.delete(key) }
68
+ transaction.after_commit do
69
+ deferred.delete(key)
70
+ perform(owner, name, timing)
71
+ rescue StandardError => error
72
+ failed(error, owner, name)
73
+ end
74
+ end
75
+
76
+ # The save has committed and the count can be rebuilt, so don't make the save look failed or
77
+ # stop the other recounts queued for this commit. unexpected still raises in development and test.
78
+ def self.failed(error, owner, name)
79
+ context = { owner: owner.class.name, owner_id: owner.id, counter: name.to_s }
80
+ owner.class.logger&.error("custom_counter_cache: recount of #{context[:owner]} #{context[:owner_id].inspect} " \
81
+ "#{context[:counter]} failed: #{error.class}: #{error.message}")
82
+ ActiveSupport.error_reporter.unexpected(error, context: context, source: 'custom_counter_cache')
83
+ end
84
+
85
+ def self.perform(owner, name, timing)
86
+ case timing
87
+ when :later then RecountJob.perform_later(owner.class.name, owner.id, name.to_s)
88
+ when :invalidate
89
+ CustomCounterCache.cache_store.delete(CustomCounterCache.cache_key(owner, name))
90
+ touch_column = owner.class.custom_counter_cache_touch_column(name)
91
+ # A cascade destroy invalidates after the owner is gone; there's nothing left to touch.
92
+ owner.update_column(touch_column, Time.current) if touch_column && !owner.destroyed?
93
+ else locked_recount(owner, name)
94
+ end
95
+ end
96
+
97
+ def self.state
98
+ ActiveSupport::IsolatedExecutionState[:custom_counter_cache] ||= {}
99
+ end
100
+ end
101
+ end
@@ -3,90 +3,125 @@ require 'active_support/concern'
3
3
  module CustomCounterCache::Model
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ included do
7
+ class_attribute :custom_counter_cache_names, instance_accessor: false, default: []
8
+ # Per counter name; a new merged hash is assigned so subclasses never mutate their parent's.
9
+ class_attribute :custom_counter_cache_options, instance_accessor: false, default: {}
10
+ end
11
+
6
12
  module ClassMethods
7
- def define_counter_cache(cache_column, &block)
8
- return unless table_exists?
9
-
10
- # counter accessors
11
- unless column_names.include?(cache_column.to_s)
12
- # Declare once per class: redeclaring for each virtual counter triggers method-redefinition warnings.
13
- has_many :counters, as: :countable, dependent: :delete_all unless reflect_on_association(:counters)
14
- define_method "#{cache_column}" do
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
13
+ # Column or counters row is decided on each call, not here, so defining never queries the database.
14
+ def define_counter_cache(cache_column, touch: nil, store: nil, expires_in: nil, &block)
15
+ raise ArgumentError, "store: must be :cache or omitted" unless store.nil? || store == :cache
16
+ name = cache_column.to_s
17
+ define_counters_association
18
+ define_cache_cleanup if store == :cache
19
+ self.custom_counter_cache_names += [name]
20
+ self.custom_counter_cache_options = custom_counter_cache_options.merge(name => { touch: touch, store: store, expires_in: expires_in })
21
+
22
+ custom_counter_cache_methods.module_eval do
23
+ define_method(name) do
24
+ case self.class.custom_counter_cache_storage(name)
25
+ when :column then super()
26
+ when :cache
27
+ CustomCounterCache.cache_store.fetch(CustomCounterCache.cache_key(self, name), expires_in: expires_in) { block.call(self) }
18
28
  else
19
- counters.find_by(key: cache_column.to_s).try(:value).to_i
29
+ # Once loaded (e.g. includes(:counters)), a missing row means 0, not a query.
30
+ counter = counters.loaded? ? counters.detect { |c| c.key == name } : counters.find_by(key: name)
31
+ counter.try(:value).to_i
20
32
  end
21
33
  end
22
- define_method "#{cache_column}=" do |count|
34
+
35
+ define_method("#{name}=") do |count|
36
+ case self.class.custom_counter_cache_storage(name)
37
+ when :column then return super(count)
38
+ when :cache then return CustomCounterCache.cache_store.write(CustomCounterCache.cache_key(self, name), count, expires_in: expires_in)
39
+ end
40
+ count = CustomCounterCache::Model.whole_number!(self, name, count)
23
41
  # 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)
42
+ counter = counters.loaded? ? counters.detect { |c| c.key == name } : counters.find_by(key: name)
25
43
  if counter
26
- counter.update_attribute :value, count.to_i
44
+ counter.update_attribute :value, count
27
45
  else
28
46
  begin
29
47
  # 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 }
48
+ self.class.transaction(requires_new: true) { counters.create key: name, value: count }
31
49
  rescue ActiveRecord::RecordNotUnique
32
50
  # Lost a create race. A locking read sees the winner's row even under REPEATABLE READ (MySQL).
33
51
  counters.reset
34
- counters.lock.find_by!(key: cache_column.to_s).update_attribute :value, count.to_i
52
+ counters.lock.find_by!(key: name).update_attribute :value, count
35
53
  end
36
54
  end
37
55
  end
38
- end
39
56
 
40
- # counter update method
41
- define_method "update_#{cache_column}" do
42
- if self.class.column_names.include?(cache_column.to_s)
43
- update_attribute cache_column, block.call(self)
44
- else
45
- send "#{cache_column}=", block.call(self)
57
+ define_method("update_#{name}") do
58
+ touch_column = self.class.custom_counter_cache_touch_column(name)
59
+ value = block.call(self)
60
+ if self.class.custom_counter_cache_storage(name) == :column
61
+ # One statement, and no callbacks: update_columns, not touch.
62
+ touch_column ? update_columns(name => value, touch_column => Time.current) : update_column(name, value)
63
+ else
64
+ send "#{name}=", value
65
+ update_column touch_column, Time.current if touch_column
66
+ end
46
67
  end
47
68
  end
69
+ end
48
70
 
49
- rescue StandardError => e
50
- # Support Heroku's database-less assets:precompile pre-deploy step:
51
- raise e unless ENV['DATABASE_URL'].to_s.include?('//user:pass@127.0.0.1/')
71
+ # :cache when declared; otherwise a column if one exists, else a row in the counters table.
72
+ def custom_counter_cache_storage(name) # :nodoc:
73
+ return :cache if custom_counter_cache_options.dig(name.to_s, :store) == :cache
74
+ return :column if column_names.include?(name.to_s)
75
+ # The counters table's single countable_id can't hold a composite key.
76
+ if composite_primary_key?
77
+ raise ArgumentError, "#{self} has a composite primary key, so counter #{name} needs a column or store: :cache"
78
+ end
79
+ :counters
52
80
  end
53
81
 
54
- def update_counter_cache(association, cache_column, options = {})
55
- return unless table_exists?
82
+ def custom_counter_cache_touch_column(name) # :nodoc:
83
+ touch = custom_counter_cache_options.dig(name.to_s, :touch)
84
+ touch == true ? 'updated_at' : (touch.to_s if touch)
85
+ end
56
86
 
57
- association = association.to_sym
87
+ # Repairs drift no callback can see (update_all, delete_all, imports). Returns the records processed.
88
+ def recount_counter_caches(*names, scope: all, batch_size: 1000)
89
+ names = names.empty? ? custom_counter_cache_names : names.map(&:to_s)
90
+ unknown = names - custom_counter_cache_names
91
+ raise ArgumentError, "#{self} has no counter cache named #{unknown.join(', ')}" if unknown.any?
92
+
93
+ # Calls update_<name> directly, so CustomCounterCache.skip and .batch don't affect it.
94
+ # Preload :counters, which the virtual counter writer uses once loaded, to avoid a query per record.
95
+ scope = scope.includes(:counters) if names.any? { |name| custom_counter_cache_storage(name) == :counters }
96
+ processed = 0
97
+ scope.find_each(batch_size: batch_size) do |record|
98
+ names.each { |name| record.public_send("update_#{name}") }
99
+ processed += 1
100
+ end
101
+ processed
102
+ end
103
+
104
+ # association may be a belongs_to path, e.g. [:article, :user], to recount a grandparent.
105
+ def update_counter_cache(association, cache_column, options = {})
106
+ path = Array(association).map(&:to_sym)
107
+ association = path.first
58
108
  cache_column = cache_column.to_sym
59
- method_name = "callback_#{association}_#{cache_column}".to_sym
109
+ method_name = "callback_#{path.join('_')}_#{cache_column}".to_sym
60
110
  reflection = reflect_on_association(association)
61
- raise ArgumentError, "#{self} must declare belongs_to :#{association} before update_counter_cache" unless reflection
111
+ raise ArgumentError, "#{self} must declare belongs_to :#{association} before update_counter_cache" unless reflection&.belongs_to?
62
112
  foreign_key = reflection.foreign_key
113
+ rest = path.drop(1)
114
+ path_checked = false
115
+ timing = CustomCounterCache.check_timing!(options.fetch(:recount, :after_commit))
63
116
 
64
- # define callback
65
117
  define_method method_name do
66
- # update old association
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) )
76
- record.send("update_#{cache_column}")
77
- end
78
- end
79
- else
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) )
83
- record.send("update_#{cache_column}")
84
- end
85
- end
86
- end
87
- # update new association
88
- if ( record = send(association) )
89
- record.send("update_#{cache_column}")
118
+ # Later steps live on classes that may not be loaded at declaration time, so check them on first use.
119
+ path_checked ||= CustomCounterCache::Model.check_path!(reflection, rest)
120
+ owners = [CustomCounterCache::Model.previous_owner(self, reflection), public_send(association)]
121
+ owners = owners.map { |record| CustomCounterCache::Model.follow_path(record, rest) }
122
+ # Moving between two records under the same grandparent must recount it once, not twice.
123
+ owners.compact.uniq { |owner| [owner.class, owner.id] }.each do |owner|
124
+ CustomCounterCache::Dispatcher.recount(owner, cache_column, timing)
90
125
  end
91
126
  end
92
127
 
@@ -97,13 +132,104 @@ module CustomCounterCache::Model
97
132
 
98
133
  # set callbacks
99
134
  callback_opts = options.slice(:if, :unless, :prepend)
135
+ update_opts = callback_opts
136
+ if options[:on_change]
137
+ # The keys are always watched so a reassignment recounts the old owner too.
138
+ watched = Array(options[:on_change]).map(&:to_s) + Array(foreign_key).map(&:to_s)
139
+ watched << reflection.foreign_type.to_s if reflection.polymorphic?
140
+ changed = ->(record) { watched.any? { |attribute| record.saved_change_to_attribute?(attribute) } }
141
+ update_opts = callback_opts.merge(if: Array(options[:if]) + [changed])
142
+ end
143
+ # Create isn't gated by :on_change: column defaults aren't saved changes, so new records would be missed.
100
144
  after_create method_name, **callback_opts unless skip_callback.call(:create, options)
101
- after_update method_name, **callback_opts unless skip_callback.call(:update, options)
102
- after_destroy method_name, **callback_opts unless skip_callback.call(:destroy, options)
145
+ after_update method_name, **update_opts unless skip_callback.call(:update, options)
146
+ # Not :if/:unless: they test saved changes, which a destroy lacks. An extra recount is never wrong.
147
+ after_destroy method_name, **options.slice(:prepend) unless skip_callback.call(:destroy, options)
148
+ # Paranoia's restore skips update callbacks; it defines :restore only once acts_as_paranoid has run.
149
+ after_restore method_name, **options.slice(:prepend) if respond_to?(:after_restore) && !skip_callback.call(:restore, options)
150
+ end
151
+
152
+ private
153
+
154
+ # Included after Active Record's generated attribute methods, so super reaches a real column's accessor.
155
+ def custom_counter_cache_methods
156
+ @custom_counter_cache_methods ||= Module.new.tap { |mod| include mod }
157
+ end
158
+
159
+ def define_cache_cleanup
160
+ return if @custom_counter_cache_cleanup
161
+ @custom_counter_cache_cleanup = true
162
+ after_destroy_commit do
163
+ self.class.custom_counter_cache_names.each do |name|
164
+ next unless self.class.custom_counter_cache_storage(name) == :cache
165
+ CustomCounterCache.cache_store.delete(CustomCounterCache.cache_key(self, name))
166
+ end
167
+ end
168
+ end
169
+
170
+ def define_counters_association
171
+ return if reflect_on_association(:counters)
172
+ has_many :counters, as: :countable, class_name: CustomCounterCache.counter_class_name
173
+ # Not dependent: :delete_all, which would query the counters table even when every counter is a column.
174
+ before_destroy do
175
+ in_table = self.class.custom_counter_cache_names.any? { |name| self.class.custom_counter_cache_storage(name) == :counters }
176
+ counters.delete_all(:delete_all) if in_table
177
+ end
178
+ end
179
+ end
180
+
181
+ # The counters table holds integers. Converting 2.5 to 2 would store a wrong count without a word, so refuse.
182
+ def self.whole_number!(record, name, value)
183
+ number =
184
+ case value
185
+ when nil then 0
186
+ when Integer then value
187
+ when String then Integer(value, 10, exception: false)
188
+ when Numeric then value.to_i if (!value.respond_to?(:finite?) || value.finite?) && value == value.to_i
189
+ end
190
+ return number if number
191
+ raise ArgumentError, "#{record.class}##{name} is stored in the counters table, which holds whole numbers; got " \
192
+ "#{value.inspect}. Give it a column of a suitable type, or use store: :cache"
193
+ end
194
+
195
+ # Checks each step up to the first polymorphic one, past which the class varies per record.
196
+ def self.check_path!(reflection, steps)
197
+ steps.each do |step|
198
+ return true if reflection.polymorphic?
199
+ owner_class = reflection.klass
200
+ reflection = owner_class.reflect_on_association(step)
201
+ raise ArgumentError, "#{owner_class} must declare belongs_to :#{step} for update_counter_cache" unless reflection&.belongs_to?
202
+ end
203
+ true
204
+ end
205
+
206
+ def self.follow_path(record, steps)
207
+ steps.reduce(record) do |current, step|
208
+ break unless current
209
+ reflection = current.class.reflect_on_association(step)
210
+ # Only reachable past a polymorphic step: this type has no such parent, so there's nothing to recount.
211
+ break unless reflection
212
+ raise ArgumentError, "#{current.class} must declare belongs_to :#{step} for update_counter_cache" unless reflection.belongs_to?
213
+ current.public_send(step)
214
+ end
215
+ end
216
+
217
+ # The owner record belonged to before its last save moved it, or nil if it didn't move.
218
+ # Keys may be composite: only some of their columns may have changed.
219
+ def self.previous_owner(record, reflection)
220
+ keys = Array(reflection.foreign_key).map(&:to_s)
221
+ keys += [reflection.foreign_type] if reflection.polymorphic?
222
+ return unless keys.any? { |key| record.saved_change_to_attribute?(key) }
223
+
224
+ old_values = keys.map { |key| record.saved_change_to_attribute?(key) ? record.attribute_before_last_save(key) : record[key] }
225
+ return if old_values.any?(&:nil?)
103
226
 
104
- rescue StandardError => e
105
- # Support Heroku's database-less assets:precompile pre-deploy step:
106
- raise e unless ENV['DATABASE_URL'].to_s.include?('//user:pass@127.0.0.1/')
227
+ if reflection.polymorphic?
228
+ # safe_: the stored type may name a class that has since been renamed or removed.
229
+ old_klass = old_values.pop.safe_constantize
230
+ old_klass&.find_by(CustomCounterCache.key_conditions(reflection.association_primary_key(old_klass), old_values))
231
+ else
232
+ reflection.klass.find_by(CustomCounterCache.key_conditions(reflection.association_primary_key, old_values))
107
233
  end
108
234
  end
109
235
  end
@@ -0,0 +1,3 @@
1
+ class CustomCounterCache::Railtie < Rails::Railtie
2
+ rake_tasks { load File.expand_path('../tasks/custom_counter_cache.rake', __dir__) }
3
+ end
@@ -0,0 +1,16 @@
1
+ begin
2
+ require 'active_job'
3
+ rescue LoadError
4
+ raise LoadError, "custom_counter_cache: recount: :later needs Active Job; add 'activejob' to your Gemfile"
5
+ end
6
+
7
+ module CustomCounterCache
8
+ # Enqueued by update_counter_cache ..., recount: :later. Takes plain values, not the record, so no GlobalID is needed.
9
+ class RecountJob < ActiveJob::Base
10
+ def perform(class_name, id, name)
11
+ klass = class_name.safe_constantize
12
+ owner = klass&.find_by(CustomCounterCache.key_conditions(klass.primary_key, id))
13
+ Dispatcher.locked_recount(owner, name) if owner
14
+ end
15
+ end
16
+ end
@@ -1,5 +1,5 @@
1
1
  module CustomCounterCache
2
2
 
3
- VERSION = '0.3.2'
3
+ VERSION = '0.4.0'
4
4
 
5
5
  end
@@ -1,3 +1,37 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext/module/attribute_accessors'
3
+
1
4
  module CustomCounterCache
2
- require 'custom_counter_cache/model'
5
+ # Model holding counters that have no column. Read when a model defines its first counter, so set it before models load.
6
+ mattr_accessor :counter_class_name, default: 'Counter'
7
+ mattr_writer :cache_store, default: nil
8
+
9
+ # Where store: :cache counters live: the configured store, else Rails.cache.
10
+ def self.cache_store
11
+ store = @@cache_store || (::Rails.cache if defined?(::Rails) && ::Rails.respond_to?(:cache))
12
+ store or raise ArgumentError, 'store: :cache needs a cache: set CustomCounterCache.cache_store (Rails.cache is used when available)'
13
+ end
14
+
15
+ def self.cache_key(owner, name)
16
+ "custom_counter_cache/v1/#{owner.class.polymorphic_name}/#{Array(owner.id).join('-')}/#{name}"
17
+ end
18
+
19
+ # Conditions matching columns to values; each may be a single key or a composite one.
20
+ def self.key_conditions(columns, values) # :nodoc:
21
+ Array(columns).map(&:to_s).zip(Array(values)).to_h
22
+ end
23
+
24
+ # Callback recounts always run after commit; :later moves them into a job.
25
+ TIMINGS = %i[after_commit later].freeze
26
+
27
+ def self.check_timing!(timing) # :nodoc:
28
+ raise ArgumentError, "recount: must be one of #{TIMINGS.join(', ')}" unless TIMINGS.include?(timing)
29
+ # Fail at class load, not on the first save, if Active Job is missing.
30
+ require 'custom_counter_cache/recount_job' if timing == :later
31
+ timing
32
+ end
3
33
  end
34
+
35
+ require 'custom_counter_cache/dispatcher'
36
+ require 'custom_counter_cache/model'
37
+ require 'custom_counter_cache/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,12 @@
1
+ namespace :custom_counter_cache do
2
+ desc 'Recount counter caches: MODEL=User [COUNTERS=articles_count,comments_count] [BATCH_SIZE=1000]'
3
+ task recount: :environment do
4
+ abort 'MODEL is required, e.g. MODEL=User' if ENV['MODEL'].to_s.empty?
5
+ model = ENV['MODEL'].safe_constantize
6
+ abort "#{ENV['MODEL']} is not a model with counter caches" unless model.respond_to?(:recount_counter_caches)
7
+
8
+ names = ENV['COUNTERS'].to_s.split(',').map(&:strip).reject(&:empty?)
9
+ batch_size = Integer(ENV.fetch('BATCH_SIZE', 1000))
10
+ puts "Recounted #{model.recount_counter_caches(*names, batch_size: batch_size)} #{model} records"
11
+ end
12
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: custom_counter_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cedric Howe
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.2'
18
+ version: '8.0'
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '9.0'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: '7.2'
28
+ version: '8.0'
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9.0'
@@ -35,7 +35,7 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '7.2'
38
+ version: '8.0'
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
41
  version: '9.0'
@@ -45,10 +45,44 @@ dependencies:
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '7.2'
48
+ version: '8.0'
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: activejob
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '8.0'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '8.0'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: discard
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - "~>"
77
+ - !ruby/object:Gem::Version
78
+ version: '2.0'
79
+ type: :development
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - "~>"
84
+ - !ruby/object:Gem::Version
85
+ version: '2.0'
52
86
  - !ruby/object:Gem::Dependency
53
87
  name: minitest
54
88
  requirement: !ruby/object:Gem::Requirement
@@ -69,6 +103,20 @@ dependencies:
69
103
  - - "<"
70
104
  - !ruby/object:Gem::Version
71
105
  version: '7'
106
+ - !ruby/object:Gem::Dependency
107
+ name: paranoia
108
+ requirement: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - "~>"
111
+ - !ruby/object:Gem::Version
112
+ version: '3.1'
113
+ type: :development
114
+ prerelease: false
115
+ version_requirements: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - "~>"
118
+ - !ruby/object:Gem::Version
119
+ version: '3.1'
72
120
  - !ruby/object:Gem::Dependency
73
121
  name: rake
74
122
  requirement: !ruby/object:Gem::Requirement
@@ -109,14 +157,19 @@ files:
109
157
  - LICENSE
110
158
  - README.rdoc
111
159
  - lib/custom_counter_cache.rb
160
+ - lib/custom_counter_cache/dispatcher.rb
112
161
  - lib/custom_counter_cache/model.rb
162
+ - lib/custom_counter_cache/railtie.rb
163
+ - lib/custom_counter_cache/recount_job.rb
113
164
  - lib/custom_counter_cache/version.rb
165
+ - lib/tasks/custom_counter_cache.rake
114
166
  homepage: https://github.com/cedric/custom_counter_cache
115
167
  licenses:
116
168
  - MIT
117
169
  metadata:
118
170
  source_code_uri: https://github.com/cedric/custom_counter_cache
119
171
  changelog_uri: https://github.com/cedric/custom_counter_cache/blob/main/CHANGELOG.md
172
+ rubygems_mfa_required: 'true'
120
173
  rdoc_options: []
121
174
  require_paths:
122
175
  - lib
@@ -124,7 +177,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
124
177
  requirements:
125
178
  - - ">="
126
179
  - !ruby/object:Gem::Version
127
- version: '3.1'
180
+ version: '3.3'
128
181
  required_rubygems_version: !ruby/object:Gem::Requirement
129
182
  requirements:
130
183
  - - ">="