support_table_cache 1.0.1 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7874d2e1669bb98537be4230846cc44862ebce2df16201d319926607a478c4b
4
- data.tar.gz: 6f9c7bd2737b23689f487bbee803a0ce7aa592a3bde8357dab98bcd8fafa5612
3
+ metadata.gz: a4593056e89633a124e0301d60245fd7ce398d30c4e4df6bb83d0a982618e7e6
4
+ data.tar.gz: 4a145eb106e73e8005df79dfff194fdfd1a6712a0452c5c782665321fc4f5e31
5
5
  SHA512:
6
- metadata.gz: f322815aac9b0074d75a148c9d6b05ddb2ecb044fdae5d80b80bfca1bc409b70820b754fbecc83ddfe368da7f01872fde0b2eec724c328d1077759b2587aec70
7
- data.tar.gz: 14733e659226be252601d4705e92248ac8377b21b0e301b6cb4b621f56731aad173c2ad01febb64a51f7051690de31b83ecc36fe69cfa4545806aebe94b38c52
6
+ metadata.gz: d11be8910f52e30c5c7b4cdb0eb9acbb0d0c0c4921ab3eba158111ebdd9635a91387f1d440d7ccc9fdb1136b4482a0110811ba6c86eb620443011c0aa9a00b14
7
+ data.tar.gz: 99df1e4070ed392e85dcb711c8cddf253733faf505d0b54d6288fb23a1c9eac67230a1a71ea05e73035133ae4fd519c0e1d9d94eb6d4d095f425faf1ca544a02
data/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.1.1
8
+
9
+ ### Fixed
10
+ - Fixed disabled and disable_cache methods to yield a block to match the documentation.
11
+
12
+ ## 1.1.0
13
+
14
+ ### Added
15
+ - Added fetch_by and fetch_by! methods that can verify the result will be cacheable.
16
+ - Allow configuring cache storage on a per class basis.
17
+ - Allow disabling caching on per class basis.
18
+ - Added optimized in-memory cache implementation.
19
+ - Added support for caching belongs to assocations.
20
+ - Added test mode to intialize new caches within a test block.
21
+
22
+ ### Changed
23
+ - Changed fiber local variables used for disabling the cache to thread local variables.
24
+ - Using find_by! on a relation will now use the cache.
25
+
7
26
  ## 1.0.1
8
27
 
9
28
  ### Added
data/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  [![Continuous Integration](https://github.com/bdurand/support_table_cache/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/support_table_cache/actions/workflows/continuous_integration.yml)
4
4
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
5
 
6
- This gem adds caching for ActiveRecord support table models. These are models which have a unique key (i.e. a unique `name` attribute, etc.) and which have a limited number of entries (a few hundred at most). These are often models added do normalize the data structure.
6
+ This gem adds caching for ActiveRecord support table models. These models have a unique key (i.e. a unique `name` attribute, etc.) and a limited number of entries (a few hundred at most). These are often models added to normalize the data structure and are also known as lookup tables.
7
7
 
8
- Rows from these kinds of tables are rarely inserted, updated, or deleted, but are queried very frequently. To take advantage of this behavior, this gem adds automatic caching for records when using the `find_by` method. This is most useful in situations where you have a unique key but need to get the database row for that key.
8
+ Rows from these kinds of tables are rarely inserted, updated, or deleted, but are queried very frequently. To take advantage of this behavior, this gem adds automatic caching for records when using the `find_by` method and `belongs_to` associations.
9
9
 
10
- For instance, suppose you have a model `Status` that has a unique name attribute and you need to process a bunch of records from a data source that includes the status name. In order to do anything, you'll need to lookup each status by name to get the database id:
10
+ For instance, suppose you have a model `Status` that has a unique name attribute and you need to process a lot of records from a data source that includes the status name. In order to do anything, you'll need to look up each status by name to get the database id:
11
11
 
12
12
  ```ruby
13
13
  params.each do |data|
@@ -16,11 +16,11 @@ params.each do |data|
16
16
  end
17
17
  ```
18
18
 
19
- With this gem, you can avoid the database query for the `find_by` call. You don't need to alter your code in any way other than to include `SupportTableCache` in your model and tell it which attributes comprise a unique key that can be used for caching.
19
+ With this gem, you can avoid the database query associated with the `find_by` call. You don't need to alter your code in any way other than to include `SupportTableCache` in your model and telling it the attributes that comprise a unique key, which can be used for caching.
20
20
 
21
21
  ## Usage
22
22
 
23
- To use the gem, you need to include it in you models and then specify which attributes can be used for caching with the `cache_by` method. A caching attribute must be a unique key on the model. For a composite key, you can specify an array of attributes. If any of the attributes are case insensitive strings, you need to specify that as well.
23
+ To use the gem, you need to include it in you models and then specify which attributes can be used for caching with the `cache_by` method. A caching attribute must be a unique key on the model. For a composite unique key, you can specify an array of attributes. If any of the attributes are case-insensitive strings, you need to specify that as well.
24
24
 
25
25
  ```ruby
26
26
  class MyModel < ApplicationRecord
@@ -39,14 +39,14 @@ To use the gem, you need to include it in you models and then specify which attr
39
39
  # Uses cache on a composite key with scoping
40
40
  MyModel.where(group: "first").find_by(name: "One")
41
41
 
42
- # Does not use cache since value is not defined as a cacheable key
42
+ # Does not use cache because the value is not defined as a cacheable key
43
43
  MyModel.find_by(value: 1)
44
44
 
45
- # Does not use caching since not using find_by
45
+ # Does not use caching because not using the find_by method
46
46
  MyModel.where(id: 1).first
47
47
  ```
48
48
 
49
- By default, records will be cleaned up from the cache only when they are modified. However, you can set a time to live on the model after which records will be removed from the cache.
49
+ By default, records will be cleaned up from the cache only when they are modified. However, you can change this by setting a time to live on the model after which records will be removed from the cache.
50
50
 
51
51
  ```ruby
52
52
  class MyModel < ApplicationRecord
@@ -56,26 +56,89 @@ By default, records will be cleaned up from the cache only when they are modifie
56
56
  end
57
57
  ```
58
58
 
59
- If you are in a Rails application, the `Rails.cache` will be used by default to cache records. Otherwise, you need to set the `ActiveSupport::Cache::CacheStore`` to use.
59
+ ### Setting the Cache
60
+
61
+ If you are in a Rails application, the `Rails.cache` will be used by default to cache records. Otherwise, you need to set the `ActiveSupport::Cache::CacheStore` instance to use.
60
62
 
61
63
  ```ruby
62
64
  SupportTableCache.cache = ActiveSupport::Cache::MemoryStore.new
63
65
  ```
64
66
 
65
- You can also disable caching behavior entirely if you want or just within a block. You may want to disable it entirely in test mode if it interferes with your tests.
67
+ You can also set a cache per class. For instance, you can set an in-memory cache on models that are never changed to avoid a network round trip to the cache server. You can use the special value `:memory` to do this.
68
+
69
+ ```ruby
70
+ class MyModel < ApplicationRecord
71
+ include SupportTableCache
72
+
73
+ self.support_table_cache = :memory
74
+ end
75
+ ```
76
+
77
+ Note that in-memory caches exist separately within each process and will not be cleared when records are changed in the database. The only way to refresh elements in an in-memory cache is to restart the process or set the `support_table_cache_ttl` value so that the entries will expire.
78
+
79
+ ### Disabling Caching
80
+
81
+ You can disable the cache within a block either globally or only for a specific class. If the cache is disabled, then all queries will pass through to the database.
66
82
 
67
83
  ```ruby
68
84
  # Disable the cache globally
69
85
  SupportTableCache.disable
70
86
 
71
87
  SupportTableCache.enable do
72
- # Re-enable the cache for the block
88
+ # Re-enable the cache within a block
73
89
  SupportTableCache.disable do
74
90
  # Disable it again
91
+ MySupportModel.enable_cache do
92
+ # Re-enable it only for the MySupportModel class
93
+ end
75
94
  end
76
95
  end
77
96
  ```
78
97
 
98
+ ### Caching Belongs to Associations
99
+
100
+ You can also cache belongs to associations to cacheable models.
101
+
102
+ To do this, you include the `SupportTableCache::Associations` module in your model and then call `cache_belongs_to` to specify which associations should be cached. You must define the association first with `belongs_to` before you can call `cache_belongs_to`. You can include `SupportTableCache::Associations` in `ApplicationRecord` if you want this behavior available to all of your models.
103
+
104
+ The target class for the association must include the `SupportTableCache` module.
105
+
106
+ ```ruby
107
+ class ParentModel < ApplicationRecord
108
+ include SupportTableCache::Associations
109
+
110
+ belongs_to :my_model
111
+ cache_belongs_to :my_model
112
+ end
113
+ ```
114
+
115
+ You can include `SupportTableCache::Associations` in your `ApplicationRecord` class to make association caching available on all models.
116
+
117
+ ### Testing
118
+
119
+ Caching may interfere with tests by allowing data created in one test to leak into subsequent tests. You can resolve this by wrapping your tests with the `SupportTableCache.testing!` method.
120
+
121
+ ```
122
+ # Rspec
123
+ RSpec.configure do |config|
124
+ config.around do |example|
125
+ SupportTableCache.testing! { example.run }
126
+ end
127
+ end
128
+
129
+ # MiniTest (with the minitest-around gem)
130
+ class MiniTest::Spec
131
+ around do |tests|
132
+ SupportTableCache.testing!(&tests)
133
+ end
134
+ =end
135
+
136
+ ```
137
+
138
+ ### Maintaining Data
139
+
140
+ You can use the companion [support_table_data gem](https://github.com/bdurand/support_table_data) to provide functionality for loading static data into your support tables as well as adding helper functions to make looking up specific rows much easier.
141
+
79
142
  ## Installation
80
143
 
81
144
  Add this line to your application's Gemfile:
@@ -84,7 +147,7 @@ Add this line to your application's Gemfile:
84
147
  gem "support_table_cache"
85
148
  ```
86
149
 
87
- And then execute:
150
+ Then execute:
88
151
  ```bash
89
152
  $ bundle
90
153
  ```
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.1
1
+ 1.1.1
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportTableCache
4
+ # Extension to non-support table models for caching belongs_to associations to support tables.
5
+ module Associations
6
+ extend ActiveSupport::Concern
7
+
8
+ module ClassMethods
9
+ # Specify that a belongs_to association should use the cache. This will override the reader method
10
+ # for the association so that it queries from the cache. The association must already be defined.
11
+ #
12
+ # If you need cached associations to be cleared when data changes, then the associated class will
13
+ # need to include SupportTableCache and cache by the primary key for the association.
14
+ #
15
+ # @param association_name [Symbol, String] The association name to cache.
16
+ # @return [void]
17
+ # @raise ArgumentError if the association is not defined or if it has a runtime scope.
18
+ def cache_belongs_to(association_name)
19
+ reflection = reflections[association_name.to_s]
20
+
21
+ unless reflection&.belongs_to?
22
+ raise ArgumentError.new("The belongs_to #{association_name} association is not defined")
23
+ end
24
+
25
+ if reflection.scopes.present?
26
+ raise ArgumentError.new("Cannot cache belongs_to #{association_name} association because it has a scope")
27
+ end
28
+
29
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
30
+ def #{association_name}_with_cache
31
+ foreign_key = self.send(#{reflection.foreign_key.inspect})
32
+ return nil if foreign_key.nil?
33
+ key = [#{reflection.class_name.inspect}, {#{reflection.association_primary_key.inspect} => foreign_key}]
34
+ cache = #{reflection.class_name}.send(:current_support_table_cache)
35
+ ttl = #{reflection.class_name}.send(:support_table_cache_ttl)
36
+ if cache
37
+ cache.fetch(key, expires_in: ttl) do
38
+ #{association_name}_without_cache
39
+ end
40
+ else
41
+ #{association_name}_without_cache
42
+ end
43
+ end
44
+
45
+ alias_method :#{association_name}_without_cache, :#{association_name}
46
+ alias_method :#{association_name}, :#{association_name}_with_cache
47
+ RUBY
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportTableCache
4
+ # @api private
5
+ module FindByOverride
6
+ # Override for the find_by method that looks in the cache first.
7
+ def find_by(*args)
8
+ cache = current_support_table_cache
9
+ return super unless cache
10
+
11
+ cache_key = nil
12
+ attributes = ((args.size == 1 && args.first.is_a?(Hash)) ? args.first.stringify_keys : {})
13
+
14
+ if respond_to?(:scope_attributes) && scope_attributes.present?
15
+ attributes = scope_attributes.stringify_keys.merge(attributes)
16
+ end
17
+
18
+ if attributes.present?
19
+ support_table_cache_by_attributes.each do |attribute_names, case_sensitive, where|
20
+ where&.each do |name, value|
21
+ if attributes.include?(name) && attributes[name] == value
22
+ attributes.delete(name)
23
+ else
24
+ return super
25
+ end
26
+ end
27
+ cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
28
+ break if cache_key
29
+ end
30
+ end
31
+
32
+ if cache_key
33
+ cache.fetch(cache_key, expires_in: support_table_cache_ttl) { super }
34
+ else
35
+ super
36
+ end
37
+ end
38
+
39
+ # Same as find_by, but performs a safety check to confirm the query will hit the cache.
40
+ #
41
+ # @param attributes [Hash] Attributes to find the record by.
42
+ # @raise ArgumentError if the query cannot use the cache.
43
+ def fetch_by(attributes)
44
+ find_by_attribute_names = support_table_find_by_attribute_names(attributes)
45
+ unless support_table_cache_by_attributes.any? { |attribute_names, _ci, _where| attribute_names == find_by_attribute_names }
46
+ raise ArgumentError.new("#{name} does not cache queries by #{find_by_attribute_names.to_sentence}")
47
+ end
48
+ find_by(attributes)
49
+ end
50
+
51
+ # Same as find_by!, but performs a safety check to confirm the query will hit the cache.
52
+ #
53
+ # @param attributes [Hash] Attributes to find the record by.
54
+ # @raise ArgumentError if the query cannot use the cache.
55
+ # @raise ActiveRecord::RecordNotFound if no record is found.
56
+ def fetch_by!(attributes)
57
+ value = fetch_by(attributes)
58
+ if value.nil?
59
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{name}", name)
60
+ end
61
+ value
62
+ end
63
+
64
+ private
65
+
66
+ def support_table_find_by_attribute_names(attributes)
67
+ attributes ||= {}
68
+ if respond_to?(:scope_attributes) && scope_attributes.present?
69
+ attributes = scope_attributes.merge(attributes)
70
+ end
71
+ attributes.keys.map(&:to_s).sort
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportTableCache
4
+ # An optimized cache implementation that can be used when all records can easily fit
5
+ # in memory and are never changed. It is intended for use with small, static support
6
+ # tables only.
7
+ #
8
+ # This cache will not store nil values. This is to prevent the cache from filling up with
9
+ # cache misses because there is no purging mechanism.
10
+ class MemoryCache
11
+ def initialize
12
+ @cache = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def fetch(key, expires_in: nil)
17
+ serialized_value, expire_at = @cache[key]
18
+ if serialized_value.nil? || (expire_at && expire_at < Process.clock_gettime(Process::CLOCK_MONOTONIC))
19
+ value = yield if block_given?
20
+ return nil if value.nil?
21
+ write(key, value, expires_in: expires_in)
22
+ serialized_value = Marshal.dump(value)
23
+ end
24
+ Marshal.load(serialized_value)
25
+ end
26
+
27
+ def read(key)
28
+ fetch(key)
29
+ end
30
+
31
+ def write(key, value, expires_in: nil)
32
+ return if value.nil?
33
+
34
+ if expires_in
35
+ expire_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + expires_in
36
+ end
37
+
38
+ serialized_value = Marshal.dump(value)
39
+
40
+ @mutex.synchronize do
41
+ @cache[key] = [serialized_value, expire_at]
42
+ end
43
+ end
44
+
45
+ def delete(key)
46
+ @cache.delete(key)
47
+ end
48
+
49
+ def clear
50
+ @cache.clear
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportTableCache
4
+ # @api private
5
+
6
+ module RelationOverride
7
+ # Override for the find_by method that looks in the cache first.
8
+ def find_by(*args)
9
+ return super unless klass.include?(SupportTableCache)
10
+
11
+ cache = klass.send(:current_support_table_cache)
12
+ return super unless cache
13
+
14
+ cache_key = nil
15
+ attributes = ((args.size == 1 && args.first.is_a?(Hash)) ? args.first.stringify_keys : {})
16
+
17
+ # Apply any attributes from the current relation chain
18
+ if scope_attributes.present?
19
+ attributes = scope_attributes.stringify_keys.merge(attributes)
20
+ end
21
+
22
+ if attributes.present?
23
+ support_table_cache_by_attributes.each do |attribute_names, case_sensitive, where|
24
+ where&.each do |name, value|
25
+ if attributes.include?(name) && attributes[name] == value
26
+ attributes.delete(name)
27
+ else
28
+ return super
29
+ end
30
+ end
31
+ cache_key = SupportTableCache.cache_key(klass, attributes, attribute_names, case_sensitive)
32
+ break if cache_key
33
+ end
34
+ end
35
+
36
+ if cache_key
37
+ cache.fetch(cache_key, expires_in: support_table_cache_ttl) { super }
38
+ else
39
+ super
40
+ end
41
+ end
42
+
43
+ # Override for the find_by! method that looks in the cache first.
44
+ # @raise ActiveRecord::RecordNotFound if no record is found.
45
+ def find_by!(*args)
46
+ value = find_by(*args)
47
+ unless value
48
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{klass.name}", klass.name)
49
+ end
50
+ value
51
+ end
52
+
53
+ # Same as find_by, but performs a safety check to confirm the query will hit the cache.
54
+ #
55
+ # @param attributes [Hash] Attributes to find the record by.
56
+ # @raise ArgumentError if the query cannot use the cache.
57
+ def fetch_by(attributes)
58
+ find_by_attribute_names = support_table_find_by_attribute_names(attributes)
59
+ unless klass.support_table_cache_by_attributes.any? { |attribute_names, _ci| attribute_names == find_by_attribute_names }
60
+ raise ArgumentError.new("#{name} does not cache queries by #{find_by_attribute_names.to_sentence}")
61
+ end
62
+ find_by(attributes)
63
+ end
64
+
65
+ # Same as find_by!, but performs a safety check to confirm the query will hit the cache.
66
+ #
67
+ # @param attributes [Hash] Attributes to find the record by.
68
+ # @raise ArgumentError if the query cannot use the cache.
69
+ # @raise ActiveRecord::RecordNotFound if no record is found.
70
+ def fetch_by!(attributes)
71
+ value = fetch_by(attributes)
72
+ if value.nil?
73
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{klass.name}", klass.name)
74
+ end
75
+ value
76
+ end
77
+
78
+ private
79
+
80
+ def support_table_find_by_attribute_names(attributes)
81
+ attributes ||= {}
82
+ if scope_attributes.present?
83
+ attributes = scope_attributes.merge(attributes)
84
+ end
85
+ attributes.keys.map(&:to_s).sort
86
+ end
87
+ end
88
+ end
@@ -1,15 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This concern can be added to a model for a support table to add the ability to lookup
4
- # entries in these table using Rails.cache when calling find_by rather than hitting the
5
- # database every time.
3
+ require_relative "support_table_cache/associations"
4
+ require_relative "support_table_cache/find_by_override"
5
+ require_relative "support_table_cache/relation_override"
6
+ require_relative "support_table_cache/memory_cache"
7
+
8
+ # This concern can be added to a model to add the ability to look up entries in the table
9
+ # using Rails.cache when calling find_by rather than hitting the database every time.
6
10
  module SupportTableCache
7
11
  extend ActiveSupport::Concern
8
12
 
9
13
  included do
14
+ # @api private Used to store the list of attribute names used for caching.
10
15
  class_attribute :support_table_cache_by_attributes, instance_accessor: false
16
+
17
+ # Set the time to live in seconds for records in the cache.
11
18
  class_attribute :support_table_cache_ttl, instance_accessor: false
12
19
 
20
+ # @api private
21
+ class_attribute :support_table_cache_impl, instance_accessor: false
22
+
13
23
  unless ActiveRecord::Relation.include?(RelationOverride)
14
24
  ActiveRecord::Relation.prepend(RelationOverride)
15
25
  end
@@ -17,43 +27,142 @@ module SupportTableCache
17
27
  class << self
18
28
  prepend FindByOverride unless include?(FindByOverride)
19
29
  private :support_table_cache_by_attributes=
30
+ private :support_table_cache_impl
31
+ private :support_table_cache_impl=
20
32
  end
21
33
 
22
34
  after_commit :support_table_clear_cache_entries
23
35
  end
24
36
 
25
- class_methods do
37
+ module ClassMethods
38
+ # Disable the caching behavior for this class within the block. The disabled setting
39
+ # for a class will always take precedence over the global setting.
40
+ #
41
+ # @param disabled [Boolean] Caching will be disabled if this is true and enabled if false.
42
+ # @yieldreturn The return value of the block.
43
+ def disable_cache(disabled = true, &block)
44
+ varname = "support_table_cache_disabled:#{name}"
45
+ save_val = Thread.current.thread_variable_get(varname)
46
+ begin
47
+ Thread.current.thread_variable_set(varname, !!disabled)
48
+ yield
49
+ ensure
50
+ Thread.current.thread_variable_set(varname, save_val)
51
+ end
52
+ end
53
+
54
+ # Enable the caching behavior for this class within the block. The enabled setting
55
+ # for a class will always take precedence over the global setting.
56
+ #
57
+ # @yieldreturn The return value of the block.
58
+ def enable_cache(&block)
59
+ disable_cache(false, &block)
60
+ end
61
+
62
+ # Load all records into the cache. You should only call this method on small tables with
63
+ # a few dozen rows at most because it will load each row one at a time.
64
+ #
65
+ # @return [void]
66
+ def load_cache
67
+ cache = current_support_table_cache
68
+ return super if cache.nil?
69
+
70
+ find_each do |record|
71
+ support_table_cache_by_attributes.each do |attribute_names, case_sensitive|
72
+ attributes = record.attributes.select { |name, value| attribute_names.include?(name) }
73
+ cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
74
+ cache.fetch(cache_key, expires_in: support_table_cache_ttl) { record }
75
+ end
76
+ end
77
+ end
78
+
79
+ # Set a class-specific cache to use in lieu of the global cache.
80
+ #
81
+ # @param cache [ActiveSupport::Cache::Store, Symbol] The cache instance to use. You can also
82
+ # specify the value :memory to use an optimized in-memory cache.
83
+ # @return [void]
84
+ def support_table_cache=(cache)
85
+ cache = MemoryCache.new if cache == :memory
86
+ self.support_table_cache_impl = cache
87
+ end
88
+
26
89
  protected
27
90
 
28
91
  # Specify which attributes can be used for looking up records in the cache. Each value must
29
92
  # define a unique key, Multiple unique keys can be specified.
93
+ #
30
94
  # If multiple attributes are used to make up a unique key, then they should be passed in as an array.
31
- # @param attributes [String, Symbol, Array<String, Symbol>] Attributes that make up a unique key.
95
+ #
96
+ # If you need to remove caching setup in a superclass, you can pass in the value false to reset
97
+ # cache behavior on the class.
98
+ #
99
+ # @param attributes [String, Symbol, Array<String, Symbol>, FalseClass] Attributes that make up a unique key.
32
100
  # @param case_sensitive [Boolean] Indicate if strings should treated as case insensitive in the key.
33
- def cache_by(attributes, case_sensitive: true)
101
+ # @param where [Hash] A hash representing a hard coded set of attributes that must match a query in order
102
+ # to cache the result. If a model has a default scope, then this value should be set to match the
103
+ # where clause in that scope.
104
+ # @return [void]
105
+ def cache_by(attributes, case_sensitive: true, where: nil)
106
+ if attributes == false
107
+ self.support_table_cache_by_attributes = []
108
+ return
109
+ end
110
+
34
111
  attributes = Array(attributes).map(&:to_s).sort.freeze
35
- self.support_table_cache_by_attributes = (support_table_cache_by_attributes || []) + [[attributes, case_sensitive]]
112
+
113
+ if where
114
+ unless where.is_a?(Hash)
115
+ raise ArgumentError.new("where must be a Hash")
116
+ end
117
+ where = where.stringify_keys
118
+ end
119
+
120
+ self.support_table_cache_by_attributes ||= []
121
+ support_table_cache_by_attributes.delete_if { |data| data.first == attributes }
122
+ self.support_table_cache_by_attributes += [[attributes, case_sensitive, where]]
123
+ end
124
+
125
+ private
126
+
127
+ def support_table_cache_disabled?
128
+ current_block_value = Thread.current.thread_variable_get("support_table_cache_disabled:#{name}")
129
+ if current_block_value.nil?
130
+ SupportTableCache.disabled?
131
+ else
132
+ current_block_value
133
+ end
134
+ end
135
+
136
+ def current_support_table_cache
137
+ return nil if support_table_cache_disabled?
138
+ SupportTableCache.testing_cache || support_table_cache_impl || SupportTableCache.cache
36
139
  end
37
140
  end
38
141
 
39
142
  class << self
40
- # Disable the caching behavior. If a block is specified, then caching is only
41
- # disabled for that block. If no block is specified, then caching is disabled
42
- # globally.
43
- # @param disabled [Boolean] Caching will be disabled if this is true, enabled if false.
143
+ # Disable the caching behavior for all classes. If a block is specified, then caching is only
144
+ # disabled for that block. If no block is specified, then caching is disabled globally.
145
+ #
146
+ # @param disabled [Boolean] Caching will be disabled if this is true and enabled if false.
147
+ # @yieldreturn The return value of the block.
44
148
  def disable(disabled = true, &block)
45
149
  if block
46
- save_val = Thread.current[:support_table_cache_disabled]
150
+ save_val = Thread.current.thread_variable_get(:support_table_cache_disabled)
47
151
  begin
48
- Thread.current[:support_table_cache_disabled] = !!disabled
152
+ Thread.current.thread_variable_set(:support_table_cache_disabled, !!disabled)
153
+ yield
49
154
  ensure
50
- Thread.current[:support_table_cache_disabled] = save_val
155
+ Thread.current.thread_variable_set(:support_table_cache_disabled, save_val)
51
156
  end
52
157
  else
53
158
  @disabled = !!disabled
54
159
  end
55
160
  end
56
161
 
162
+ # Enable the caching behavior for all classes. If a block is specified, then caching is only
163
+ # enabled for that block. If no block is specified, then caching is enabled globally.
164
+ #
165
+ # @yieldreturn The return value of the block.
57
166
  def enable(&block)
58
167
  disable(false, &block)
59
168
  end
@@ -61,7 +170,7 @@ module SupportTableCache
61
170
  # Return true if caching has been disabled.
62
171
  # @return [Boolean]
63
172
  def disabled?
64
- block_value = Thread.current[:support_table_cache_disabled]
173
+ block_value = Thread.current.thread_variable_get(:support_table_cache_disabled)
65
174
  if block_value.nil?
66
175
  !!(defined?(@disabled) && @disabled)
67
176
  else
@@ -69,19 +178,63 @@ module SupportTableCache
69
178
  end
70
179
  end
71
180
 
72
- attr_writer :cache
181
+ # Set the global cache to use.
182
+ # @param value [ActiveSupport::Cache::Store, Symbol] The cache instance to use. You can also
183
+ # specify the value :memory to use an optimized in-memory cache.
184
+ # @return [void]
185
+ def cache=(value)
186
+ value = MemoryCache.new if value == :memory
187
+ @cache = value
188
+ end
73
189
 
190
+ # Get the global cache (will default to `Rails.cache` if running in a Rails environment).
191
+ #
192
+ # @return [ActiveSupport::Cache::Store]
74
193
  def cache
75
- if defined?(@cache)
194
+ if testing_cache
195
+ testing_cache
196
+ elsif defined?(@cache)
76
197
  @cache
77
198
  elsif defined?(Rails.cache)
78
199
  Rails.cache
79
200
  end
80
201
  end
81
202
 
82
- # Generate a consistent cache key for a set of attributes. Returns nil if the attributes
203
+ # Enter test mode for a block. New caches will be used within each test mode block. You
204
+ # can use this to wrap your test methods so that cached values from one test don't show up
205
+ # in subsequent tests.
206
+ #
207
+ # @return [void]
208
+ def testing!(&block)
209
+ save_val = Thread.current.thread_variable_get(:support_table_cache_test_cache)
210
+ if save_val.nil?
211
+ Thread.current.thread_variable_set(:support_table_cache_test_cache, MemoryCache.new)
212
+ end
213
+ begin
214
+ yield
215
+ ensure
216
+ Thread.current.thread_variable_set(:support_table_cache_test_cache, save_val)
217
+ end
218
+ end
219
+
220
+ # Get the current test mode cache. This will only return a value inside of a `testing!` block.
221
+ #
222
+ # @return [SupportTableCache::MemoryCache]
223
+ # @api private
224
+ def testing_cache
225
+ unless defined?(@cache) && @cache.nil?
226
+ Thread.current.thread_variable_get(:support_table_cache_test_cache)
227
+ end
228
+ end
229
+
230
+ # Generate a consistent cache key for a set of attributes. It will return nil if the attributes
83
231
  # are not cacheable.
84
- # @param klass [Class] The class to
232
+ #
233
+ # @param klass [Class] The class that is being cached.
234
+ # @param attributes [Hash] The attributes used to find a record.
235
+ # @param key_attribute_names [Array] List of attributes that can be used as a key in the cache.
236
+ # @param case_sensitive [Boolean] Indicator if string values are case-sensitive in the cache key.
237
+ # @return [String]
85
238
  # @api private
86
239
  def cache_key(klass, attributes, key_attribute_names, case_sensitive)
87
240
  return nil if attributes.blank? || key_attribute_names.blank?
@@ -102,67 +255,15 @@ module SupportTableCache
102
255
  end
103
256
  end
104
257
 
105
- module FindByOverride
106
- # Override for the find_by method that looks in the cache first.
107
- def find_by(*args)
108
- return super if SupportTableCache.cache.nil? || SupportTableCache.disabled?
109
-
110
- cache_key = nil
111
- attributes = args.first if args.size == 1 && args.first.is_a?(Hash)
112
-
113
- if respond_to?(:scope_attributes) && scope_attributes.present?
114
- attributes = scope_attributes.merge(attributes || {})
115
- end
116
-
117
- if attributes.present?
118
- support_table_cache_by_attributes.each do |attribute_names, case_sensitive|
119
- cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
120
- break if cache_key
121
- end
122
- end
123
-
124
- if cache_key
125
- SupportTableCache.cache.fetch(cache_key, expires_in: support_table_cache_ttl) { super }
126
- else
127
- super
128
- end
129
- end
130
- end
131
-
132
- module RelationOverride
133
- # Override for the find_by method that looks in the cache first.
134
- def find_by(*args)
135
- return super unless klass.include?(SupportTableCache)
136
- return super if SupportTableCache.cache.nil? || SupportTableCache.disabled?
137
-
138
- cache_key = nil
139
- attributes = args.first if args.size == 1 && args.first.is_a?(Hash)
140
-
141
- # Apply any attributes from the current relation chain
142
- if scope_attributes.present?
143
- attributes = scope_attributes.merge(attributes || {})
144
- end
145
-
146
- if attributes.present?
147
- support_table_cache_by_attributes.each do |attribute_names, case_sensitive|
148
- cache_key = SupportTableCache.cache_key(klass, attributes, attribute_names, case_sensitive)
149
- break if cache_key
150
- end
151
- end
152
-
153
- if cache_key
154
- SupportTableCache.cache.fetch(cache_key, expires_in: support_table_cache_ttl) { super }
155
- else
156
- super
157
- end
158
- end
159
- end
160
-
161
258
  # Remove the cache entry for this record.
259
+ #
162
260
  # @return [void]
163
261
  def uncache
164
262
  cache_by_attributes = self.class.support_table_cache_by_attributes
165
- return if cache_by_attributes.blank? || SupportTableCache.cache.nil?
263
+ return if cache_by_attributes.blank?
264
+
265
+ cache = self.class.send(:current_support_table_cache)
266
+ return if cache.nil?
166
267
 
167
268
  cache_by_attributes.each do |attribute_names, case_sensitive|
168
269
  attributes = {}
@@ -170,7 +271,7 @@ module SupportTableCache
170
271
  attributes[name] = self[name]
171
272
  end
172
273
  cache_key = SupportTableCache.cache_key(self.class, attributes, attribute_names, case_sensitive)
173
- SupportTableCache.cache.delete(cache_key)
274
+ cache.delete(cache_key)
174
275
  end
175
276
  end
176
277
 
@@ -181,7 +282,10 @@ module SupportTableCache
181
282
  # and after the change.
182
283
  def support_table_clear_cache_entries
183
284
  cache_by_attributes = self.class.support_table_cache_by_attributes
184
- return if cache_by_attributes.blank? || SupportTableCache.cache.nil?
285
+ return if cache_by_attributes.blank?
286
+
287
+ cache = self.class.send(:current_support_table_cache)
288
+ return if cache.nil?
185
289
 
186
290
  cache_by_attributes.each do |attribute_names, case_sensitive|
187
291
  attributes_before = {} if saved_change_to_id.blank? || saved_change_to_id.first.present?
@@ -196,7 +300,7 @@ module SupportTableCache
196
300
  end
197
301
  [attributes_before, attributes_after].compact.uniq.each do |attributes|
198
302
  cache_key = SupportTableCache.cache_key(self.class, attributes, attribute_names, case_sensitive)
199
- SupportTableCache.cache.delete(cache_key)
303
+ cache.delete(cache_key)
200
304
  end
201
305
  end
202
306
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: support_table_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-05-17 00:00:00.000000000 Z
11
+ date: 2022-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -50,6 +50,10 @@ files:
50
50
  - README.md
51
51
  - VERSION
52
52
  - lib/support_table_cache.rb
53
+ - lib/support_table_cache/associations.rb
54
+ - lib/support_table_cache/find_by_override.rb
55
+ - lib/support_table_cache/memory_cache.rb
56
+ - lib/support_table_cache/relation_override.rb
53
57
  - support_table_cache.gemspec
54
58
  homepage: https://github.com/bdurand/support_table_cache
55
59
  licenses: