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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -0
- data/LICENSE +9 -0
- data/README.rdoc +261 -0
- data/lib/custom_counter_cache/dispatcher.rb +101 -0
- data/lib/custom_counter_cache/model.rb +197 -62
- data/lib/custom_counter_cache/railtie.rb +3 -0
- data/lib/custom_counter_cache/recount_job.rb +16 -0
- data/lib/custom_counter_cache/version.rb +1 -1
- data/lib/custom_counter_cache.rb +35 -1
- data/lib/tasks/custom_counter_cache.rake +12 -0
- metadata +126 -19
- data/test/counter_test.rb +0 -282
- data/test/test_helper.rb +0 -138
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
define_method
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
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_#{
|
|
109
|
+
method_name = "callback_#{path.join('_')}_#{cache_column}".to_sym
|
|
54
110
|
reflection = reflect_on_association(association)
|
|
55
|
-
|
|
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
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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, **
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
#
|
|
97
|
-
|
|
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,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
|
data/lib/custom_counter_cache.rb
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
require 'active_support'
|
|
2
|
+
require 'active_support/core_ext/module/attribute_accessors'
|
|
3
|
+
|
|
1
4
|
module CustomCounterCache
|
|
2
|
-
|
|
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.
|
|
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:
|
|
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
|
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
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: '
|
|
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
|
-
-
|
|
57
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
185
|
+
version: '0'
|
|
76
186
|
requirements: []
|
|
77
|
-
rubygems_version:
|
|
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: []
|