identity_cache 0.5.1 → 1.0.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.
Files changed (91) hide show
  1. checksums.yaml +5 -5
  2. data/.github/probots.yml +2 -0
  3. data/.github/workflows/ci.yml +26 -0
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +5 -0
  6. data/.travis.yml +24 -9
  7. data/CHANGELOG.md +21 -0
  8. data/Gemfile +5 -1
  9. data/README.md +28 -26
  10. data/Rakefile +14 -5
  11. data/dev.yml +9 -16
  12. data/gemfiles/Gemfile.latest-release +6 -0
  13. data/gemfiles/Gemfile.rails-edge +6 -0
  14. data/gemfiles/Gemfile.rails52 +6 -0
  15. data/identity_cache.gemspec +26 -10
  16. data/lib/identity_cache.rb +49 -46
  17. data/lib/identity_cache/belongs_to_caching.rb +12 -40
  18. data/lib/identity_cache/cache_fetcher.rb +6 -5
  19. data/lib/identity_cache/cache_hash.rb +2 -2
  20. data/lib/identity_cache/cache_invalidation.rb +4 -11
  21. data/lib/identity_cache/cache_key_generation.rb +17 -65
  22. data/lib/identity_cache/cache_key_loader.rb +128 -0
  23. data/lib/identity_cache/cached.rb +7 -0
  24. data/lib/identity_cache/cached/association.rb +87 -0
  25. data/lib/identity_cache/cached/attribute.rb +123 -0
  26. data/lib/identity_cache/cached/attribute_by_multi.rb +37 -0
  27. data/lib/identity_cache/cached/attribute_by_one.rb +88 -0
  28. data/lib/identity_cache/cached/belongs_to.rb +93 -0
  29. data/lib/identity_cache/cached/embedded_fetching.rb +41 -0
  30. data/lib/identity_cache/cached/prefetcher.rb +51 -0
  31. data/lib/identity_cache/cached/primary_index.rb +97 -0
  32. data/lib/identity_cache/cached/recursive/association.rb +68 -0
  33. data/lib/identity_cache/cached/recursive/has_many.rb +9 -0
  34. data/lib/identity_cache/cached/recursive/has_one.rb +9 -0
  35. data/lib/identity_cache/cached/reference/association.rb +16 -0
  36. data/lib/identity_cache/cached/reference/has_many.rb +105 -0
  37. data/lib/identity_cache/cached/reference/has_one.rb +100 -0
  38. data/lib/identity_cache/configuration_dsl.rb +53 -215
  39. data/lib/identity_cache/encoder.rb +95 -0
  40. data/lib/identity_cache/expiry_hook.rb +36 -0
  41. data/lib/identity_cache/fallback_fetcher.rb +2 -1
  42. data/lib/identity_cache/load_strategy/eager.rb +28 -0
  43. data/lib/identity_cache/load_strategy/lazy.rb +71 -0
  44. data/lib/identity_cache/load_strategy/load_request.rb +20 -0
  45. data/lib/identity_cache/load_strategy/multi_load_request.rb +27 -0
  46. data/lib/identity_cache/memoized_cache_proxy.rb +127 -58
  47. data/lib/identity_cache/parent_model_expiration.rb +45 -11
  48. data/lib/identity_cache/query_api.rb +128 -394
  49. data/lib/identity_cache/railtie.rb +8 -0
  50. data/lib/identity_cache/record_not_found.rb +6 -0
  51. data/lib/identity_cache/should_use_cache.rb +1 -0
  52. data/lib/identity_cache/version.rb +3 -2
  53. data/lib/identity_cache/with_primary_index.rb +136 -0
  54. data/lib/identity_cache/without_primary_index.rb +24 -3
  55. data/performance/cache_runner.rb +28 -34
  56. data/performance/cpu.rb +3 -2
  57. data/performance/externals.rb +4 -3
  58. data/performance/profile.rb +6 -5
  59. data/railgun.yml +16 -0
  60. metadata +44 -73
  61. data/Gemfile.rails42 +0 -6
  62. data/Gemfile.rails50 +0 -6
  63. data/test/attribute_cache_test.rb +0 -110
  64. data/test/cache_fetch_includes_test.rb +0 -46
  65. data/test/cache_hash_test.rb +0 -14
  66. data/test/cache_invalidation_test.rb +0 -139
  67. data/test/deeply_nested_associated_record_test.rb +0 -19
  68. data/test/denormalized_has_many_test.rb +0 -214
  69. data/test/denormalized_has_one_test.rb +0 -160
  70. data/test/fetch_multi_test.rb +0 -308
  71. data/test/fetch_test.rb +0 -258
  72. data/test/fixtures/serialized_record.mysql2 +0 -0
  73. data/test/fixtures/serialized_record.postgresql +0 -0
  74. data/test/helpers/active_record_objects.rb +0 -106
  75. data/test/helpers/database_connection.rb +0 -72
  76. data/test/helpers/serialization_format.rb +0 -51
  77. data/test/helpers/update_serialization_format.rb +0 -27
  78. data/test/identity_cache_test.rb +0 -29
  79. data/test/index_cache_test.rb +0 -161
  80. data/test/memoized_attributes_test.rb +0 -59
  81. data/test/memoized_cache_proxy_test.rb +0 -107
  82. data/test/normalized_belongs_to_test.rb +0 -107
  83. data/test/normalized_has_many_test.rb +0 -231
  84. data/test/normalized_has_one_test.rb +0 -9
  85. data/test/prefetch_associations_test.rb +0 -379
  86. data/test/readonly_test.rb +0 -109
  87. data/test/recursive_denormalized_has_many_test.rb +0 -131
  88. data/test/save_test.rb +0 -82
  89. data/test/schema_change_test.rb +0 -112
  90. data/test/serialization_format_change_test.rb +0 -16
  91. data/test/test_helper.rb +0 -140
@@ -1,76 +1,19 @@
1
+ # frozen_string_literal: true
1
2
  module IdentityCache
2
3
  module ConfigurationDSL
3
4
  extend ActiveSupport::Concern
4
5
 
5
6
  included do |base|
6
- base.class_attribute :cache_indexes
7
- base.class_attribute :cached_has_manys
8
- base.class_attribute :cached_has_ones
9
- base.class_attribute :primary_cache_index_enabled
7
+ base.class_attribute(:cache_indexes)
8
+ base.class_attribute(:cached_has_manys)
9
+ base.class_attribute(:cached_has_ones)
10
10
 
11
11
  base.cached_has_manys = {}
12
12
  base.cached_has_ones = {}
13
13
  base.cache_indexes = []
14
- base.primary_cache_index_enabled = true
15
-
16
- base.after_commit :expire_parent_caches
17
14
  end
18
15
 
19
16
  module ClassMethods
20
- # Declares a new index in the cache for the class where IdentityCache was
21
- # included.
22
- #
23
- # IdentityCache will add a fetch_by_field1_and_field2_and_...field for every
24
- # index.
25
- #
26
- # == Example:
27
- #
28
- # class Product
29
- # include IdentityCache
30
- # cache_index :name, :vendor
31
- # end
32
- #
33
- # Will add Product.fetch_by_name_and_vendor
34
- #
35
- # == Parameters
36
- #
37
- # +fields+ Array of symbols or strings representing the fields in the index
38
- #
39
- # == Options
40
- # * unique: if the index would only have unique values
41
- #
42
- def cache_index(*fields)
43
- raise NotImplementedError, "Cache indexes need an enabled primary index" unless primary_cache_index_enabled
44
- options = fields.extract_options!
45
- unique = options[:unique] || false
46
- cache_attribute_by_alias('primary_key', 'id', by: fields, unique: unique)
47
-
48
- field_list = fields.join("_and_")
49
- arg_list = (0...fields.size).collect { |i| "arg#{i}" }.join(',')
50
-
51
- if unique
52
- self.instance_eval(ruby = <<-CODE, __FILE__, __LINE__ + 1)
53
- def fetch_by_#{field_list}(#{arg_list}, options={})
54
- id = fetch_id_by_#{field_list}(#{arg_list})
55
- id && fetch_by_id(id, options)
56
- end
57
-
58
- # exception throwing variant
59
- def fetch_by_#{field_list}!(#{arg_list}, options={})
60
- fetch_by_#{field_list}(#{arg_list}, options) or raise ActiveRecord::RecordNotFound
61
- end
62
- CODE
63
- else
64
- self.instance_eval(ruby = <<-CODE, __FILE__, __LINE__ + 1)
65
- def fetch_by_#{field_list}(#{arg_list}, options={})
66
- ids = fetch_id_by_#{field_list}(#{arg_list})
67
- ids.empty? ? ids : fetch_multi(ids, options)
68
- end
69
- CODE
70
- end
71
- end
72
-
73
-
74
17
  # Will cache an association to the class including IdentityCache.
75
18
  # The embed option, if set, will make IdentityCache keep the association
76
19
  # values in the same cache entry as the parent.
@@ -84,10 +27,8 @@ module IdentityCache
84
27
  # include IdentityCache
85
28
  # has_many :options
86
29
  # has_many :orders
87
- # has_many :buyers
88
30
  # cache_has_many :options, embed: :ids
89
31
  # cache_has_many :orders
90
- # cache_has_many :buyers, inverse_name: 'line_item'
91
32
  # end
92
33
  #
93
34
  # == Parameters
@@ -95,27 +36,28 @@ module IdentityCache
95
36
  #
96
37
  # == Options
97
38
  #
98
- # * embed: If set to true, will cause IdentityCache to keep the
99
- # values for this association in the same cache entry as the parent,
100
- # instead of its own.
101
- # * inverse_name: The name of the parent in the association if the name is
102
- # not the lowercase pluralization of the parent object's class
103
- def cache_has_many(association, options = {})
39
+ # * embed: If `true`, IdentityCache will embed the associated records
40
+ # in the cache entries for this model, as well as all the embedded
41
+ # associations for the associated record recursively.
42
+ # If `:ids` (the default), it will only embed the ids for the associated
43
+ # records.
44
+ def cache_has_many(association, embed: :ids)
104
45
  ensure_base_model
105
- options = options.slice(:embed, :inverse_name)
106
- options[:embed] = :ids unless options.has_key?(:embed)
107
- deprecate_embed_option(options, false, :ids)
108
- ensure_cacheable_association(association, options)
109
- self.cached_has_manys[association] = options
110
-
111
- case options[:embed]
112
- when true
113
- build_recursive_association_cache(association, options)
46
+ check_association_for_caching(association)
47
+ reflection = reflect_on_association(association)
48
+ association_class = case embed
114
49
  when :ids
115
- build_id_embedded_has_many_cache(association, options)
50
+ Cached::Reference::HasMany
51
+ when true
52
+ Cached::Recursive::HasMany
116
53
  else
117
54
  raise NotImplementedError
118
55
  end
56
+
57
+ cached_has_manys[association] = association_class.new(
58
+ association,
59
+ reflection: reflection,
60
+ ).tap(&:build)
119
61
  end
120
62
 
121
63
  # Will cache an association to the class including IdentityCache.
@@ -125,7 +67,7 @@ module IdentityCache
125
67
  # == Example:
126
68
  # class Product
127
69
  # cache_has_one :store, embed: true
128
- # cache_has_one :vendor
70
+ # cache_has_one :vendor, embed: :id
129
71
  # end
130
72
  #
131
73
  # == Parameters
@@ -133,24 +75,27 @@ module IdentityCache
133
75
  #
134
76
  # == Options
135
77
  #
136
- # * embed: Only true is supported, which is also the default, so
137
- # IdentityCache will keep the values for this association in the same
138
- # cache entry as the parent, instead of its own.
139
- # * inverse_name: The name of the parent in the association ( only
140
- # necessary if the name is not the lowercase pluralization of the
141
- # parent object's class)
142
- def cache_has_one(association, options = {})
78
+ # * embed: If `true`, IdentityCache will embed the associated record
79
+ # in the cache entries for this model, as well as all the embedded
80
+ # associations for the associated record recursively.
81
+ # If `:id`, it will only embed the id for the associated record.
82
+ def cache_has_one(association, embed:)
143
83
  ensure_base_model
144
- options = options.slice(:embed, :inverse_name)
145
- options[:embed] = true unless options.has_key?(:embed)
146
- ensure_cacheable_association(association, options)
147
- self.cached_has_ones[association] = options
148
-
149
- if options[:embed] == true
150
- build_recursive_association_cache(association, options)
84
+ check_association_for_caching(association)
85
+ reflection = reflect_on_association(association)
86
+ association_class = case embed
87
+ when :id
88
+ Cached::Reference::HasOne
89
+ when true
90
+ Cached::Recursive::HasOne
151
91
  else
152
92
  raise NotImplementedError
153
93
  end
94
+
95
+ cached_has_ones[association] = association_class.new(
96
+ association,
97
+ reflection: reflection,
98
+ ).tap(&:build)
154
99
  end
155
100
 
156
101
  # Will cache a single attribute on its own blob, it will add a
@@ -170,145 +115,38 @@ module IdentityCache
170
115
  #
171
116
  # * by: Other attribute or attributes in the model to keep values indexed. Default is :id
172
117
  # * unique: if the index would only have unique values. Default is true
173
- def cache_attribute(attribute, options = {})
174
- cache_attribute_by_alias(attribute.inspect, attribute, options)
175
- end
176
-
177
- def disable_primary_cache_index
178
- ActiveSupport::Deprecation.warn("disable_primary_cache_index is deprecated, use `include IdentityCache::WithoutPrimaryIndex` instead")
179
- ensure_base_model
180
- self.primary_cache_index_enabled = false
118
+ def cache_attribute(attribute, by: :id, unique: true)
119
+ cache_attribute_by_alias(attribute, alias_name: attribute, by: by, unique: unique)
181
120
  end
182
121
 
183
122
  private
184
123
 
185
- def cache_attribute_by_alias(attribute, alias_name, options)
124
+ def cache_attribute_by_alias(attribute_or_proc, alias_name:, by:, unique:)
186
125
  ensure_base_model
187
- options[:by] ||= :id
188
- alias_name = alias_name.to_sym
189
- unique = options[:unique].nil? ? true : !!options[:unique]
190
- fields = Array(options[:by])
191
-
192
- self.cache_indexes.push [alias_name, fields, unique]
193
-
194
- field_list = fields.join("_and_")
195
- arg_list = (0...fields.size).collect { |i| "arg#{i}" }.join(',')
196
-
197
- self.instance_eval(<<-CODE, __FILE__, __LINE__ + 1)
198
- def fetch_#{alias_name}_by_#{field_list}(#{arg_list})
199
- attribute_dynamic_fetcher(#{attribute}, #{fields.inspect}, [#{arg_list}], #{unique})
200
- end
201
- CODE
202
- end
203
-
204
- def build_recursive_association_cache(association, options) #:nodoc:
205
- options[:association_reflection] = reflect_on_association(association)
206
- options[:cached_accessor_name] = "fetch_#{association}"
207
- options[:records_variable_name] = "cached_#{association}"
208
-
209
- self.class_eval(<<-CODE, __FILE__, __LINE__ + 1)
210
- def #{options[:cached_accessor_name]}
211
- fetch_recursively_cached_association('#{options[:records_variable_name]}', :#{association})
212
- end
213
- CODE
214
-
215
- options[:only_on_foreign_key_change] = false
216
- add_parent_expiry_hook(options)
217
- end
218
-
219
- def build_id_embedded_has_many_cache(association, options) #:nodoc:
220
- singular_association = association.to_s.singularize
221
- options[:association_reflection] = reflect_on_association(association)
222
- options[:cached_accessor_name] = "fetch_#{association}"
223
- options[:ids_name] = "#{singular_association}_ids"
224
- options[:cached_ids_name] = "fetch_#{options[:ids_name]}"
225
- options[:ids_variable_name] = "cached_#{options[:ids_name]}"
226
- options[:records_variable_name] = "cached_#{association}"
227
- options[:prepopulate_method_name] = "prepopulate_fetched_#{association}"
228
-
229
- self.class_eval(<<-CODE, __FILE__, __LINE__ + 1)
230
- attr_reader :#{options[:ids_variable_name]}
231
-
232
- def #{options[:cached_ids_name]}
233
- @#{options[:ids_variable_name]} ||= #{options[:ids_name]}
234
- end
235
-
236
- def #{options[:cached_accessor_name]}
237
- association_klass = association(:#{association}).klass
238
- if association_klass.should_use_cache? && !#{association}.loaded?
239
- @#{options[:records_variable_name]} ||= #{options[:association_reflection].klass}.fetch_multi(#{options[:cached_ids_name]})
240
- else
241
- #{association}.to_a
242
- end
243
- end
244
-
245
- def #{options[:prepopulate_method_name]}(records)
246
- @#{options[:records_variable_name]} = records
247
- end
248
- CODE
126
+ fields = Array(by)
249
127
 
250
- options[:only_on_foreign_key_change] = true
251
- add_parent_expiry_hook(options)
252
- end
253
-
254
- def attribute_dynamic_fetcher(attribute, fields, values, unique_index) #:nodoc:
255
- raise_if_scoped
256
-
257
- if should_use_cache?
258
- cache_key = rails_cache_key_for_attribute_and_fields_and_values(attribute, fields, values, unique_index)
259
- IdentityCache.fetch(cache_key) do
260
- dynamic_attribute_cache_miss(attribute, fields, values, unique_index)
261
- end
262
- else
263
- dynamic_attribute_cache_miss(attribute, fields, values, unique_index)
264
- end
265
- end
266
-
267
- def dynamic_attribute_cache_miss(attribute, fields, values, unique_index)
268
- query = reorder(nil).where(Hash[fields.zip(values)])
269
- query = query.limit(1) if unique_index
270
- results = query.pluck(attribute)
271
- unique_index ? results.first : results
272
- end
273
-
274
- def add_parent_expiry_hook(options)
275
- child_class = options[:association_reflection].klass
276
- unless child_class < IdentityCache
277
- message = "associated class #{child_class} will need to include IdentityCache or " \
278
- "IdentityCache::WithoutPrimaryIndex for embedded associations"
279
- ActiveSupport::Deprecation.warn(message, caller(3))
280
- child_class.send(:include, IdentityCache::WithoutPrimaryIndex)
281
- end
282
- child_class.parent_expiration_entries[options[:inverse_name]] << [self, options[:only_on_foreign_key_change]]
283
- end
284
-
285
- def deprecate_embed_option(options, old_value, new_value)
286
- if options[:embed] == old_value
287
- options[:embed] = new_value
288
- ActiveSupport::Deprecation.warn("`embed: #{old_value.inspect}` was renamed to `embed: #{new_value.inspect}` for clarity", caller(2))
289
- end
128
+ klass = fields.one? ? Cached::AttributeByOne : Cached::AttributeByMulti
129
+ cached_attribute = klass.new(self, attribute_or_proc, alias_name, fields, unique)
130
+ cached_attribute.build
131
+ cache_indexes.push(cached_attribute)
290
132
  end
291
133
 
292
134
  def ensure_base_model
293
135
  if self != cached_model
294
- raise DerivedModelError, "IdentityCache class methods must be called on the same model that includes IdentityCache"
136
+ raise DerivedModelError, <<~MSG.squish
137
+ IdentityCache class methods must be called on the same
138
+ model that includes IdentityCache
139
+ MSG
295
140
  end
296
141
  end
297
142
 
298
- def ensure_cacheable_association(association, options)
299
- unless association_reflection = self.reflect_on_association(association)
143
+ def check_association_for_caching(association)
144
+ unless (association_reflection = reflect_on_association(association))
300
145
  raise AssociationError, "Association named '#{association}' was not found on #{self.class}"
301
146
  end
302
147
  if association_reflection.options[:through]
303
148
  raise UnsupportedAssociationError, "caching through associations isn't supported"
304
149
  end
305
- options[:inverse_name] ||= association_reflection.inverse_of.name if association_reflection.inverse_of
306
- options[:inverse_name] ||= self.name.underscore.to_sym
307
- child_class = association_reflection.klass
308
- raise InverseAssociationError unless child_class.reflect_on_association(options[:inverse_name])
309
- unless options[:embed] == true || child_class.include?(IdentityCache)
310
- raise UnsupportedAssociationError, "associated class #{child_class} must include IdentityCache to be cached without full embedding"
311
- end
312
150
  end
313
151
  end
314
152
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+ module IdentityCache
3
+ module Encoder
4
+ DEHYDRATE_EVENT = "dehydration.identity_cache"
5
+ HYDRATE_EVENT = "hydration.identity_cache"
6
+
7
+ class << self
8
+ def encode(record)
9
+ return unless record
10
+
11
+ ActiveSupport::Notifications.instrument(DEHYDRATE_EVENT, class: record.class.name) do
12
+ coder_from_record(record, record.class)
13
+ end
14
+ end
15
+
16
+ def decode(coder, klass)
17
+ return unless coder
18
+
19
+ ActiveSupport::Notifications.instrument(HYDRATE_EVENT, class: klass.name) do
20
+ record_from_coder(coder, klass)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def coder_from_record(record, klass)
27
+ return unless record
28
+
29
+ coder = {}
30
+ coder[:attributes] = record.attributes_before_type_cast.dup
31
+
32
+ recursively_embedded_associations = klass.send(:recursively_embedded_associations)
33
+ id_embedded_has_manys = klass.cached_has_manys.select { |_, association| association.embedded_by_reference? }
34
+ id_embedded_has_ones = klass.cached_has_ones.select { |_, association| association.embedded_by_reference? }
35
+
36
+ if recursively_embedded_associations.present?
37
+ coder[:associations] = recursively_embedded_associations.each_with_object({}) do |(name, association), hash|
38
+ hash[name] = IdentityCache.map_cached_nil_for(embedded_coder(record, name, association))
39
+ end
40
+ end
41
+
42
+ if id_embedded_has_manys.present?
43
+ coder[:association_ids] = id_embedded_has_manys.each_with_object({}) do |(name, association), hash|
44
+ hash[name] = record.instance_variable_get(association.ids_variable_name)
45
+ end
46
+ end
47
+
48
+ if id_embedded_has_ones.present?
49
+ coder[:association_id] = id_embedded_has_ones.each_with_object({}) do |(name, association), hash|
50
+ hash[name] = record.instance_variable_get(association.id_variable_name)
51
+ end
52
+ end
53
+
54
+ coder
55
+ end
56
+
57
+ def embedded_coder(record, _association, cached_association)
58
+ embedded_record_or_records = record.public_send(cached_association.cached_accessor_name)
59
+
60
+ if embedded_record_or_records.respond_to?(:to_ary)
61
+ embedded_record_or_records.map do |embedded_record|
62
+ coder_from_record(embedded_record, embedded_record.class)
63
+ end
64
+ else
65
+ coder_from_record(embedded_record_or_records, embedded_record_or_records.class)
66
+ end
67
+ end
68
+
69
+ def record_from_coder(coder, klass) #:nodoc:
70
+ record = klass.instantiate(coder[:attributes].dup)
71
+
72
+ if coder.key?(:associations)
73
+ coder[:associations].each do |name, value|
74
+ record.instance_variable_set(klass.cached_association(name).dehydrated_variable_name, value)
75
+ end
76
+ end
77
+ if coder.key?(:association_ids)
78
+ coder[:association_ids].each do |name, ids|
79
+ record.instance_variable_set(klass.cached_has_manys.fetch(name).ids_variable_name, ids)
80
+ end
81
+ end
82
+ if coder.key?(:association_id)
83
+ coder[:association_id].each do |name, id|
84
+ record.instance_variable_set(klass.cached_has_ones.fetch(name).id_variable_name, id)
85
+ end
86
+ end
87
+
88
+ record.readonly! if IdentityCache.fetch_read_only_records
89
+ record
90
+ end
91
+ end
92
+ end
93
+
94
+ private_constant :Encoder
95
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ module IdentityCache
3
+ class ExpiryHook
4
+ def initialize(cached_association)
5
+ @cached_association = cached_association
6
+ end
7
+
8
+ def install
9
+ cached_association.validate
10
+ entry = [parent_class, only_on_foreign_key_change?]
11
+ child_class.parent_expiration_entries[inverse_name] << entry
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :cached_association
17
+
18
+ def only_on_foreign_key_change?
19
+ cached_association.embedded_by_reference?
20
+ end
21
+
22
+ def inverse_name
23
+ cached_association.inverse_name
24
+ end
25
+
26
+ def parent_class
27
+ cached_association.reflection.active_record
28
+ end
29
+
30
+ def child_class
31
+ cached_association.reflection.klass
32
+ end
33
+ end
34
+
35
+ private_constant :ExpiryHook
36
+ end