custom_counter_cache 0.3.1 → 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.
@@ -3,81 +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
- # Only declare the :counters association once per class -- a model
13
- # can have multiple virtual (non-column) counter caches, and
14
- # redeclaring has_many :counters for each one just redefines the
15
- # same reader/writer methods again, which Ruby warns about under -w.
16
- has_many :counters, as: :countable, dependent: :delete_all unless reflect_on_association(:counters)
17
- 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
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) }
21
28
  else
22
- 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
23
32
  end
24
33
  end
25
- define_method "#{cache_column}=" do |count|
26
- if ( counter = counters.find_by(key: cache_column.to_s) )
27
- counter.update_attribute :value, count.to_i
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)
41
+ # Update the loaded Counter itself, or the reader keeps returning its stale value.
42
+ counter = counters.loaded? ? counters.detect { |c| c.key == name } : counters.find_by(key: name)
43
+ if counter
44
+ counter.update_attribute :value, count
28
45
  else
29
- counters.create key: cache_column.to_s, value: count.to_i
46
+ begin
47
+ # Savepoint: on PostgreSQL a failed INSERT would otherwise abort the caller's whole transaction.
48
+ self.class.transaction(requires_new: true) { counters.create key: name, value: count }
49
+ rescue ActiveRecord::RecordNotUnique
50
+ # Lost a create race. A locking read sees the winner's row even under REPEATABLE READ (MySQL).
51
+ counters.reset
52
+ counters.lock.find_by!(key: name).update_attribute :value, count
53
+ end
30
54
  end
31
55
  end
32
- end
33
56
 
34
- # counter update method
35
- define_method "update_#{cache_column}" do
36
- if self.class.column_names.include?(cache_column.to_s)
37
- update_attribute cache_column, block.call(self)
38
- else
39
- 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
40
67
  end
41
68
  end
69
+ end
42
70
 
43
- rescue StandardError => e
44
- # Support Heroku's database-less assets:precompile pre-deploy step:
45
- 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
46
80
  end
47
81
 
48
- def update_counter_cache(association, cache_column, options = {})
49
- 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
86
+
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
50
103
 
51
- association = association.to_sym
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
52
108
  cache_column = cache_column.to_sym
53
- method_name = "callback_#{association}_#{cache_column}".to_sym
109
+ method_name = "callback_#{path.join('_')}_#{cache_column}".to_sym
54
110
  reflection = reflect_on_association(association)
55
- foreign_key = reflection.try(:foreign_key) || reflection.association_foreign_key
111
+ raise ArgumentError, "#{self} must declare belongs_to :#{association} before update_counter_cache" unless reflection&.belongs_to?
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))
56
116
 
57
- # define callback
58
117
  define_method method_name do
59
- # 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
- record.send("update_#{cache_column}")
68
- end
69
- end
70
- else
71
- if send("saved_change_to_#{foreign_key}?")
72
- old_id = send("#{foreign_key}_before_last_save")
73
- if ( old_id && record = reflection.klass.find_by(id: old_id) )
74
- record.send("update_#{cache_column}")
75
- end
76
- end
77
- end
78
- # update new association
79
- if ( record = send(association) )
80
- 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)
81
125
  end
82
126
  end
83
127
 
@@ -88,13 +132,104 @@ module CustomCounterCache::Model
88
132
 
89
133
  # set callbacks
90
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.
91
144
  after_create method_name, **callback_opts unless skip_callback.call(:create, options)
92
- after_update method_name, **callback_opts unless skip_callback.call(:update, options)
93
- 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?)
94
226
 
95
- rescue StandardError => e
96
- # Support Heroku's database-less assets:precompile pre-deploy step:
97
- 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))
98
233
  end
99
234
  end
100
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.1'
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,22 +1,21 @@
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.4.0
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
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '7.2'
18
+ version: '8.0'
20
19
  - - "<"
21
20
  - !ruby/object:Gem::Version
22
21
  version: '9.0'
@@ -26,10 +25,112 @@ dependencies:
26
25
  requirements:
27
26
  - - ">="
28
27
  - !ruby/object:Gem::Version
29
- version: '7.2'
28
+ version: '8.0'
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: '8.0'
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: '8.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
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'
86
+ - !ruby/object:Gem::Dependency
87
+ name: minitest
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '5.0'
93
+ - - "<"
94
+ - !ruby/object:Gem::Version
95
+ version: '7'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '5.0'
103
+ - - "<"
104
+ - !ruby/object:Gem::Version
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'
120
+ - !ruby/object:Gem::Dependency
121
+ name: rake
122
+ requirement: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - "~>"
125
+ - !ruby/object:Gem::Version
126
+ version: '13.0'
127
+ type: :development
128
+ prerelease: false
129
+ version_requirements: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - "~>"
132
+ - !ruby/object:Gem::Version
133
+ version: '13.0'
33
134
  - !ruby/object:Gem::Dependency
34
135
  name: sqlite3
35
136
  requirement: !ruby/object:Gem::Requirement
@@ -44,22 +145,31 @@ dependencies:
44
145
  - - "~>"
45
146
  - !ruby/object:Gem::Version
46
147
  version: '2.0'
47
- description: ''
148
+ description: Define counter caches computed by an arbitrary block (e.g. a scoped count),
149
+ refreshed by callbacks on any number of associated models, and stored in a column
150
+ or a shared polymorphic counters table.
48
151
  email: cedric@howe.net
49
152
  executables: []
50
153
  extensions: []
51
154
  extra_rdoc_files: []
52
155
  files:
156
+ - CHANGELOG.md
157
+ - LICENSE
158
+ - README.rdoc
53
159
  - lib/custom_counter_cache.rb
160
+ - lib/custom_counter_cache/dispatcher.rb
54
161
  - lib/custom_counter_cache/model.rb
162
+ - lib/custom_counter_cache/railtie.rb
163
+ - lib/custom_counter_cache/recount_job.rb
55
164
  - 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/
165
+ - lib/tasks/custom_counter_cache.rake
166
+ homepage: https://github.com/cedric/custom_counter_cache
59
167
  licenses:
60
168
  - MIT
61
- metadata: {}
62
- post_install_message:
169
+ metadata:
170
+ source_code_uri: https://github.com/cedric/custom_counter_cache
171
+ changelog_uri: https://github.com/cedric/custom_counter_cache/blob/main/CHANGELOG.md
172
+ rubygems_mfa_required: 'true'
63
173
  rdoc_options: []
64
174
  require_paths:
65
175
  - lib
@@ -67,18 +177,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
177
  requirements:
68
178
  - - ">="
69
179
  - !ruby/object:Gem::Version
70
- version: '3.0'
180
+ version: '3.3'
71
181
  required_rubygems_version: !ruby/object:Gem::Requirement
72
182
  requirements:
73
183
  - - ">="
74
184
  - !ruby/object:Gem::Version
75
- version: 1.3.6
185
+ version: '0'
76
186
  requirements: []
77
- rubygems_version: 3.5.22
78
- signing_key:
187
+ rubygems_version: 4.0.21
79
188
  specification_version: 4
80
189
  summary: Custom counter_cache functionality that supports conditions and multiple
81
190
  models.
82
- test_files:
83
- - test/counter_test.rb
84
- - test/test_helper.rb
191
+ test_files: []