support_table_cache 1.0.0 → 1.1.0

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: 6c7814572a411ad62d5077cf255ff24059d2ab9292be81ded041de7ecff8a6ac
4
- data.tar.gz: 015ea25fb5083ce1a80579a143486bf44a091fa375bf0d122b0889763321852c
3
+ metadata.gz: d66ca180334bd4b561b514dab26606f7ddc0979136a3cbca0479982734ad5703
4
+ data.tar.gz: 0e156f0ea38520098710748d1e5ecad71df3aecdb0df4ff78f8be92d075dbd3f
5
5
  SHA512:
6
- metadata.gz: c4a1f00fa0873f0e28501119c5b588a6df166f5f9bcee84fcaf3dc4ffe0985251a6d6e6ee24f00d2ee740bd02b1dfe74db425a7a8170d8d5ddd98fbed0c8ecde
7
- data.tar.gz: 81de8cda7452731a341e82752f3a25ab793c1b15e518c41c78118348841b41f5f8c56af13304e2565e9bfeffc00210764bf0351e4920d56af0e4e49704fdfde7
6
+ metadata.gz: 1f3b29d73e1cb683689888513b87ae6ceff74c871744ebdb30bb271789b6c4942355c773de4fad80a0d5f4d65f35462b3d49043f7119858a725ce39243e74e47
7
+ data.tar.gz: 9a2b3e9d9a79f3b91178f1218668d4b6d388397e85e7863e4e4746a9e89d934c3e012a6d709099671073dd2e03035842d22fcb9e15c9b8ad3cf2062cc56074d3
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.0
8
+
9
+ ### Added
10
+ - Added fetch_by and fetch_by! methods that can verify the result will be cacheable.
11
+ - Allow configuring cache storage on a per class basis.
12
+ - Allow disabling caching on per class basis.
13
+ - Added optimized in-memory cache implementation.
14
+ - Added support for caching belongs to assocations.
15
+ - Added test mode to intialize new caches within a test block.
16
+
17
+ ### Changed
18
+ - Changed fiber local variables used for disabling the cache to thread local variables.
19
+ - Using find_by! on a relation will now use the cache.
20
+
21
+ ## 1.0.1
22
+
23
+ ### Added
24
+ - Preserve scope on relations terminated with a `find_by`.
25
+
7
26
  ## 1.0.0
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
@@ -36,14 +36,17 @@ To use the gem, you need to include it in you models and then specify which attr
36
36
  # Uses cache on a composite key
37
37
  MyModel.find_by(group: "first", name: "One")
38
38
 
39
- # Does not use cache since value is not defined as a cacheable key
39
+ # Uses cache on a composite key with scoping
40
+ MyModel.where(group: "first").find_by(name: "One")
41
+
42
+ # Does not use cache because the value is not defined as a cacheable key
40
43
  MyModel.find_by(value: 1)
41
44
 
42
- # Does not use caching since not using find_by
45
+ # Does not use caching because not using the find_by method
43
46
  MyModel.where(id: 1).first
44
47
  ```
45
48
 
46
- 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.
47
50
 
48
51
  ```ruby
49
52
  class MyModel < ApplicationRecord
@@ -53,26 +56,89 @@ By default, records will be cleaned up from the cache only when they are modifie
53
56
  end
54
57
  ```
55
58
 
56
- 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.
57
62
 
58
63
  ```ruby
59
64
  SupportTableCache.cache = ActiveSupport::Cache::MemoryStore.new
60
65
  ```
61
66
 
62
- 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.
63
82
 
64
83
  ```ruby
65
84
  # Disable the cache globally
66
85
  SupportTableCache.disable
67
86
 
68
87
  SupportTableCache.enable do
69
- # Re-enable the cache for the block
88
+ # Re-enable the cache within a block
70
89
  SupportTableCache.disable do
71
90
  # Disable it again
91
+ MySupportModel.enable_cache do
92
+ # Re-enable it only for the MySupportModel class
93
+ end
94
+ end
95
+ end
96
+ ```
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 }
72
126
  end
73
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
+
74
136
  ```
75
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
+
76
142
  ## Installation
77
143
 
78
144
  Add this line to your application's Gemfile:
@@ -81,7 +147,7 @@ Add this line to your application's Gemfile:
81
147
  gem "support_table_cache"
82
148
  ```
83
149
 
84
- And then execute:
150
+ Then execute:
85
151
  ```bash
86
152
  $ bundle
87
153
  ```
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.1.0
@@ -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 if cache.nil?
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 if cache.nil?
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,55 +1,166 @@
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
+
23
+ unless ActiveRecord::Relation.include?(RelationOverride)
24
+ ActiveRecord::Relation.prepend(RelationOverride)
25
+ end
26
+
13
27
  class << self
14
28
  prepend FindByOverride unless include?(FindByOverride)
15
29
  private :support_table_cache_by_attributes=
30
+ private :support_table_cache_impl
31
+ private :support_table_cache_impl=
16
32
  end
17
33
 
18
34
  after_commit :support_table_clear_cache_entries
19
35
  end
20
36
 
21
- 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
+ ensure
49
+ Thread.current.thread_variable_set(varname, save_val)
50
+ end
51
+ end
52
+
53
+ # Enable the caching behavior for this class within the block. The enabled setting
54
+ # for a class will always take precedence over the global setting.
55
+ #
56
+ # @return [void]
57
+ def enable_cache
58
+ disable_cache(false, &block)
59
+ end
60
+
61
+ # Load all records into the cache. You should only call this method on small tables with
62
+ # a few dozen rows at most because it will load each row one at a time.
63
+ #
64
+ # @return [void]
65
+ def load_cache
66
+ cache = current_support_table_cache
67
+ return super if cache.nil?
68
+
69
+ find_each do |record|
70
+ support_table_cache_by_attributes.each do |attribute_names, case_sensitive|
71
+ attributes = record.attributes.select { |name, value| attribute_names.include?(name) }
72
+ cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
73
+ cache.fetch(cache_key, expires_in: support_table_cache_ttl) { record }
74
+ end
75
+ end
76
+ end
77
+
78
+ # Set a class-specific cache to use in lieu of the global cache.
79
+ #
80
+ # @param cache [ActiveSupport::Cache::Store, Symbol] The cache instance to use. You can also
81
+ # specify the value :memory to use an optimized in-memory cache.
82
+ # @return [void]
83
+ def support_table_cache=(cache)
84
+ cache = MemoryCache.new if cache == :memory
85
+ self.support_table_cache_impl = cache
86
+ end
87
+
22
88
  protected
23
89
 
24
90
  # Specify which attributes can be used for looking up records in the cache. Each value must
25
91
  # define a unique key, Multiple unique keys can be specified.
92
+ #
26
93
  # If multiple attributes are used to make up a unique key, then they should be passed in as an array.
27
- # @param attributes [String, Symbol, Array<String, Symbol>] Attributes that make up a unique key.
94
+ #
95
+ # If you need to remove caching setup in a superclass, you can pass in the value false to reset
96
+ # cache behavior on the class.
97
+ #
98
+ # @param attributes [String, Symbol, Array<String, Symbol>, FalseClass] Attributes that make up a unique key.
28
99
  # @param case_sensitive [Boolean] Indicate if strings should treated as case insensitive in the key.
29
- def cache_by(attributes, case_sensitive: true)
100
+ # @param where [Hash] A hash representing a hard coded set of attributes that must match a query in order
101
+ # to cache the result. If a model has a default scope, then this value should be set to match the
102
+ # where clause in that scope.
103
+ # @return [void]
104
+ def cache_by(attributes, case_sensitive: true, where: nil)
105
+ if attributes == false
106
+ self.support_table_cache_by_attributes = []
107
+ return
108
+ end
109
+
30
110
  attributes = Array(attributes).map(&:to_s).sort.freeze
31
- self.support_table_cache_by_attributes = (support_table_cache_by_attributes || []) + [[attributes, case_sensitive]]
111
+
112
+ if where
113
+ unless where.is_a?(Hash)
114
+ raise ArgumentError.new("where must be a Hash")
115
+ end
116
+ where = where.stringify_keys
117
+ end
118
+
119
+ self.support_table_cache_by_attributes ||= []
120
+ support_table_cache_by_attributes.delete_if { |data| data.first == attributes }
121
+ self.support_table_cache_by_attributes += [[attributes, case_sensitive, where]]
122
+ end
123
+
124
+ private
125
+
126
+ def support_table_cache_disabled?
127
+ current_block_value = Thread.current.thread_variable_get("support_table_cache_disabled:#{name}")
128
+ if current_block_value.nil?
129
+ SupportTableCache.disabled?
130
+ else
131
+ current_block_value
132
+ end
133
+ end
134
+
135
+ def current_support_table_cache
136
+ return nil? if support_table_cache_disabled?
137
+ SupportTableCache.testing_cache || support_table_cache_impl || SupportTableCache.cache
32
138
  end
33
139
  end
34
140
 
35
141
  class << self
36
- # Disable the caching behavior. If a block is specified, then caching is only
37
- # disabled for that block. If no block is specified, then caching is disabled
38
- # globally.
39
- # @param disabled [Boolean] Caching will be disabled if this is true, enabled if false.
142
+ # Disable the caching behavior for all classes. If a block is specified, then caching is only
143
+ # disabled for that block. If no block is specified, then caching is disabled globally.
144
+ #
145
+ # @param disabled [Boolean] Caching will be disabled if this is true and enabled if false.
146
+ # @yieldreturn The return value of the block.
40
147
  def disable(disabled = true, &block)
41
148
  if block
42
- save_val = Thread.current[:support_table_cache_disabled]
149
+ save_val = Thread.current.thread_variable_get(:support_table_cache_disabled)
43
150
  begin
44
- Thread.current[:support_table_cache_disabled] = !!disabled
151
+ Thread.current.thread_variable_set(:support_table_cache_disabled, !!disabled)
45
152
  ensure
46
- Thread.current[:support_table_cache_disabled] = save_val
153
+ Thread.current.thread_variable_set(:support_table_cache_disabled, save_val)
47
154
  end
48
155
  else
49
156
  @disabled = !!disabled
50
157
  end
51
158
  end
52
159
 
160
+ # Enable the caching behavior for all classes. If a block is specified, then caching is only
161
+ # enabled for that block. If no block is specified, then caching is enabled globally.
162
+ #
163
+ # @yieldreturn The return value of the block.
53
164
  def enable(&block)
54
165
  disable(false, &block)
55
166
  end
@@ -57,7 +168,7 @@ module SupportTableCache
57
168
  # Return true if caching has been disabled.
58
169
  # @return [Boolean]
59
170
  def disabled?
60
- block_value = Thread.current[:support_table_cache_disabled]
171
+ block_value = Thread.current.thread_variable_get(:support_table_cache_disabled)
61
172
  if block_value.nil?
62
173
  !!(defined?(@disabled) && @disabled)
63
174
  else
@@ -65,19 +176,63 @@ module SupportTableCache
65
176
  end
66
177
  end
67
178
 
68
- attr_writer :cache
179
+ # Set the global cache to use.
180
+ # @param value [ActiveSupport::Cache::Store, Symbol] The cache instance to use. You can also
181
+ # specify the value :memory to use an optimized in-memory cache.
182
+ # @return [void]
183
+ def cache=(value)
184
+ value = MemoryCache.new if value == :memory
185
+ @cache = value
186
+ end
69
187
 
188
+ # Get the global cache (will default to `Rails.cache` if running in a Rails environment).
189
+ #
190
+ # @return [ActiveSupport::Cache::Store]
70
191
  def cache
71
- if defined?(@cache)
192
+ if testing_cache
193
+ testing_cache
194
+ elsif defined?(@cache)
72
195
  @cache
73
196
  elsif defined?(Rails.cache)
74
197
  Rails.cache
75
198
  end
76
199
  end
77
200
 
78
- # Generate a consistent cache key for a set of attributes. Returns nil if the attributes
201
+ # Enter test mode for a block. New caches will be used within each test mode block. You
202
+ # can use this to wrap your test methods so that cached values from one test don't show up
203
+ # in subsequent tests.
204
+ #
205
+ # @return [void]
206
+ def testing!(&block)
207
+ save_val = Thread.current.thread_variable_get(:support_table_cache_test_cache)
208
+ if save_val.nil?
209
+ Thread.current.thread_variable_set(:support_table_cache_test_cache, MemoryCache.new)
210
+ end
211
+ begin
212
+ yield
213
+ ensure
214
+ Thread.current.thread_variable_set(:support_table_cache_test_cache, save_val)
215
+ end
216
+ end
217
+
218
+ # Get the current test mode cache. This will only return a value inside of a `testing!` block.
219
+ #
220
+ # @return [SupportTableCache::MemoryCache]
221
+ # @api private
222
+ def testing_cache
223
+ unless defined?(@cache) && @cache.nil?
224
+ Thread.current.thread_variable_get(:support_table_cache_test_cache)
225
+ end
226
+ end
227
+
228
+ # Generate a consistent cache key for a set of attributes. It will return nil if the attributes
79
229
  # are not cacheable.
80
- # @param klass [Class] The class to
230
+ #
231
+ # @param klass [Class] The class that is being cached.
232
+ # @param attributes [Hash] The attributes used to find a record.
233
+ # @param key_attribute_names [Array] List of attributes that can be used as a key in the cache.
234
+ # @param case_sensitive [Boolean] Indicator if string values are case-sensitive in the cache key.
235
+ # @return [String]
81
236
  # @api private
82
237
  def cache_key(klass, attributes, key_attribute_names, case_sensitive)
83
238
  return nil if attributes.blank? || key_attribute_names.blank?
@@ -98,33 +253,15 @@ module SupportTableCache
98
253
  end
99
254
  end
100
255
 
101
- module FindByOverride
102
- # Override for the find_by method that looks in the cache first.
103
- def find_by(*args)
104
- return super if SupportTableCache.cache.nil? || SupportTableCache.disabled?
105
-
106
- cache_key = nil
107
- attributes = args.first if args.size == 1 && args.first.is_a?(Hash)
108
- if attributes
109
- support_table_cache_by_attributes.each do |attribute_names, case_sensitive|
110
- cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
111
- break if cache_key
112
- end
113
- end
114
-
115
- if cache_key
116
- SupportTableCache.cache.fetch(cache_key, expires_in: support_table_cache_ttl) { super }
117
- else
118
- super
119
- end
120
- end
121
- end
122
-
123
256
  # Remove the cache entry for this record.
257
+ #
124
258
  # @return [void]
125
259
  def uncache
126
260
  cache_by_attributes = self.class.support_table_cache_by_attributes
127
- return if cache_by_attributes.blank? || SupportTableCache.cache.nil?
261
+ return if cache_by_attributes.blank?
262
+
263
+ cache = self.class.send(:current_support_table_cache)
264
+ return if cache.nil?
128
265
 
129
266
  cache_by_attributes.each do |attribute_names, case_sensitive|
130
267
  attributes = {}
@@ -132,7 +269,7 @@ module SupportTableCache
132
269
  attributes[name] = self[name]
133
270
  end
134
271
  cache_key = SupportTableCache.cache_key(self.class, attributes, attribute_names, case_sensitive)
135
- SupportTableCache.cache.delete(cache_key)
272
+ cache.delete(cache_key)
136
273
  end
137
274
  end
138
275
 
@@ -143,7 +280,10 @@ module SupportTableCache
143
280
  # and after the change.
144
281
  def support_table_clear_cache_entries
145
282
  cache_by_attributes = self.class.support_table_cache_by_attributes
146
- return if cache_by_attributes.blank? || SupportTableCache.cache.nil?
283
+ return if cache_by_attributes.blank?
284
+
285
+ cache = self.class.send(:current_support_table_cache)
286
+ return if cache.nil?
147
287
 
148
288
  cache_by_attributes.each do |attribute_names, case_sensitive|
149
289
  attributes_before = {} if saved_change_to_id.blank? || saved_change_to_id.first.present?
@@ -158,7 +298,7 @@ module SupportTableCache
158
298
  end
159
299
  [attributes_before, attributes_after].compact.uniq.each do |attributes|
160
300
  cache_key = SupportTableCache.cache_key(self.class, attributes, attribute_names, case_sensitive)
161
- SupportTableCache.cache.delete(cache_key)
301
+ cache.delete(cache_key)
162
302
  end
163
303
  end
164
304
  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.0
4
+ version: 1.1.0
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-16 00:00:00.000000000 Z
11
+ date: 2022-11-02 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: