support_table_cache 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 919adfaf6654d3accefd95fbba6cc116484780d0e73bc23c975da5e310c10173
4
- data.tar.gz: 5c93b1ffc131dddc417b5de585f38ba89e4872e7d4a009705a0b8c669d7a6dd9
3
+ metadata.gz: 9fe5a9190c32271b431faed20e0b6ddbeb195cd05c927cae94c03b5c7d20bc8a
4
+ data.tar.gz: 8f8bc59c1a0eb6240712e50fb34da28f45d071e1c6b59e28d647d8bcf7e17896
5
5
  SHA512:
6
- metadata.gz: 922e53c4a25b835aadf77fa0bdc18af93d70406476ab08a4ee72828dad79265203d89fef68963260666cb44cc1990bccffa0034f43376e9dc1cc632debaad87c
7
- data.tar.gz: 756b44a620b8a6138358d23fcb4739c927d630c7e26685f173e9643d136b9ec41ddf045e77078a0bb73ada847dc46a5b952ccbb35693cd682fa0c0e8e38d92d8
6
+ metadata.gz: c833468a94fed3f16efd45022c56cbf19c755d6f0e2f48a2d93b8b92c2d946bd46f246170a623f2edeade892aa017851d581794fc7505c4efd5c5bce3fdf29b9
7
+ data.tar.gz: d4307c2d653184fe890e8aa8af0c548ef340d817141fddcb69e8a083ffcd235fa7d0d124b98e6ece0dd99e5a8fc549db1a8a986acf571aada17cb6f0619c67e1
data/AGENTS.md ADDED
@@ -0,0 +1,74 @@
1
+ # Copilot Instructions for support_table_cache
2
+
3
+ ## Project Overview
4
+
5
+ This is a Ruby gem that adds transparent caching to ActiveRecord support/lookup tables. It intercepts `find_by` queries and `belongs_to` associations to cache small, rarely-changing reference tables (statuses, types, categories) without code changes.
6
+
7
+ **Core principle**: Cache entries keyed by unique attribute combinations, auto-invalidated on record changes via `after_commit` callbacks.
8
+
9
+ ## Architecture
10
+
11
+ - **lib/support_table_cache.rb**: Main module with `cache_by` DSL, cache configuration, and invalidation logic
12
+ - **lib/support_table_cache/find_by_override.rb**: Prepends to model class to intercept `find_by` calls
13
+ - **lib/support_table_cache/relation_override.rb**: Prepends to `ActiveRecord::Relation` to handle scoped queries (e.g., `where(group: 'x').find_by(name: 'y')`)
14
+ - **lib/support_table_cache/associations.rb**: Extends `belongs_to` with `cache_belongs_to` to cache foreign key lookups
15
+ - **lib/support_table_cache/memory_cache.rb**: In-process cache implementation (use `support_table_cache = :memory`)
16
+
17
+ See [ARCHITECTURE.md](../ARCHITECTURE.md) for detailed flow diagrams showing cache key generation, invalidation, and association caching sequences.
18
+
19
+ ## Key Patterns
20
+
21
+ ### Model Configuration
22
+ Models use `cache_by` to declare unique keys that can be cached. Support composite keys and case-insensitivity:
23
+ ```ruby
24
+ cache_by :name, case_sensitive: false
25
+ cache_by [:group, :code]
26
+ cache_by :name, where: {deleted_at: nil} # For default scopes
27
+ ```
28
+
29
+ ### Cache Key Structure
30
+ Cache keys are `[ClassName, {attr1: val1, attr2: val2}]` arrays with sorted attribute names. Case-insensitive values are downcased before keying.
31
+
32
+ ### Module Prepending Pattern
33
+ Uses `prepend` to wrap ActiveRecord methods (`find_by`) rather than monkey-patching. This allows `super` to call original behavior on cache misses or when caching disabled.
34
+
35
+ ## Testing
36
+
37
+ - **Multi-version testing**: Uses Appraisal gem to test against ActiveRecord 5.0-8.0 (see [Appraisals](../Appraisals))
38
+ - **Run tests**: `bundle exec rspec` (default rake task) or `bundle exec appraisal rspec` for all versions
39
+ - **Test setup**: In-memory SQLite database created in [spec/spec_helper.rb](../spec/spec_helper.rb) with test tables
40
+ - **Test isolation**: Tests wrapped with `SupportTableCache.testing!` in RSpec `config.before` to prevent cache pollution
41
+
42
+ ### Code Style
43
+ Use **standardrb** for linting. Run `standardrb --fix` before committing. CI enforces this on ActiveRecord 8.0 matrix entry.
44
+
45
+ ## Common Operations
46
+
47
+ ### Adding Cache Support to Models
48
+ 1. Include `SupportTableCache` in model class
49
+ 2. Call `cache_by` with unique key attributes
50
+ 3. Optionally set `self.support_table_cache_ttl = 5.minutes`
51
+ 4. For associations: include `SupportTableCache::Associations` in parent model, then `cache_belongs_to :association_name`
52
+
53
+ ### Cache Invalidation
54
+ Automatic via `after_commit` callback that clears all cache key variations (both old and new attribute values on updates). No manual invalidation needed unless using in-memory cache across processes.
55
+
56
+ ### Debugging Cache Behavior
57
+ - Use `fetch_by` instead of `find_by` to raise error if query won't hit cache
58
+ - Disable caching in block: `Model.disable_cache { ... }` or globally `SupportTableCache.disable { ... }`
59
+ - Check if caching enabled: inspect `support_table_cache_by_attributes` class attribute
60
+
61
+ ## Development Workflow
62
+
63
+ 1. **Running specs locally**: `bundle exec rspec` (uses Ruby 3.3+ and ActiveRecord 8.0 from Gemfile)
64
+ 2. **Testing specific AR version**: `bundle exec appraisal activerecord_7 rspec`
65
+ 3. **Generating all gemfiles**: `bundle exec appraisal generate`
66
+ 4. **Lint before commit**: `standardrb --fix`
67
+ 5. **Release**: Only from `main` branch (enforced by `Rakefile` pre-release check)
68
+
69
+ ## Important Constraints
70
+
71
+ - **Target models**: Only for small tables (few hundred rows max)
72
+ - **Unique keys only**: `cache_by` attributes must define unique constraints
73
+ - **No runtime scopes**: Cannot use `cache_belongs_to` with scoped associations (checked at configuration time)
74
+ - **In-memory cache caveat**: Per-process, not invalidated across processes—only use for truly static data or with TTL
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,386 @@
1
+ # Support Table Cache Architecture
2
+
3
+ This document describes the architecture and design of the `support_table_cache` gem, which provides automatic caching for ActiveRecord support table models.
4
+
5
+ ## Overview
6
+
7
+ Support Table Cache is designed to optimize database queries for small lookup tables (support tables) that have:
8
+ - Unique keys (e.g., unique `name` attribute)
9
+ - Limited number of entries (a few hundred at most)
10
+ - Rarely updated data but frequently queried
11
+ - Used for data normalization (lookup tables)
12
+
13
+ The gem automatically caches records when using `find_by` methods and `belongs_to` associations, eliminating redundant database queries.
14
+
15
+ ## High-Level Architecture
16
+
17
+ ```mermaid
18
+ flowchart TB
19
+ subgraph "Application Layer"
20
+ App["Application Code"]
21
+ Models["ActiveRecord Models"]
22
+ end
23
+
24
+ subgraph "Support Table Cache Gem"
25
+ STC["SupportTableCache Module"]
26
+ Assoc["Associations Module"]
27
+ FindBy["FindByOverride"]
28
+ RelOverride["RelationOverride"]
29
+ MemCache["MemoryCache"]
30
+ end
31
+
32
+ subgraph "Cache Layer"
33
+ RC["Rails.cache / Custom Cache"]
34
+ MC["In-Memory Cache"]
35
+ end
36
+
37
+ subgraph "Database Layer"
38
+ DB["PostgreSQL/MySQL/SQLite"]
39
+ end
40
+
41
+ App --> Models
42
+ Models --> STC
43
+ STC --> FindBy
44
+ STC --> Assoc
45
+ STC --> RelOverride
46
+ STC --> MemCache
47
+
48
+ FindBy --> RC
49
+ FindBy --> MC
50
+ Assoc --> RC
51
+ Assoc --> MC
52
+ RelOverride --> RC
53
+ RelOverride --> MC
54
+
55
+ FindBy -.-> DB
56
+ Assoc -.-> DB
57
+ RelOverride -.-> DB
58
+
59
+ RC -.-> DB
60
+ MC -.-> DB
61
+
62
+ classDef appLayer fill:#e1f5fe
63
+ classDef cacheLayer fill:#fff3e0
64
+ classDef dbLayer fill:#f3e5f5
65
+ classDef gemLayer fill:#e8f5e8
66
+
67
+ class App,Models appLayer
68
+ class STC,Assoc,FindBy,RelOverride,MemCache gemLayer
69
+ class RC,MC cacheLayer
70
+ class DB dbLayer
71
+ ```
72
+
73
+ ## Core Components
74
+
75
+ ### 1. SupportTableCache Module
76
+
77
+ The main module that provides caching functionality to ActiveRecord models.
78
+
79
+ ```mermaid
80
+ flowchart LR
81
+ subgraph "SupportTableCache Module"
82
+ CM["Class Methods"]
83
+ IM["Instance Methods"]
84
+ CC["Cache Configuration"]
85
+ CCE["Cache Control & Expiry"]
86
+ end
87
+
88
+ subgraph "Class Methods"
89
+ CB["cache_by()"]
90
+ DC["disable_cache()"]
91
+ EC["enable_cache()"]
92
+ LC["load_cache()"]
93
+ SC["support_table_cache="]
94
+ end
95
+
96
+ subgraph "Instance Methods"
97
+ UC["uncache()"]
98
+ ClearCache["support_table_clear_cache_entries()"]
99
+ end
100
+
101
+ CM --> CB
102
+ CM --> DC
103
+ CM --> EC
104
+ CM --> LC
105
+ CM --> SC
106
+
107
+ IM --> UC
108
+ IM --> ClearCache
109
+
110
+ CB --> CC
111
+ SC --> CC
112
+ UC --> CCE
113
+ ClearCache --> CCE
114
+ ```
115
+
116
+ ### 2. Cache Key Generation Flow
117
+
118
+ ```mermaid
119
+ sequenceDiagram
120
+ participant App as Application
121
+ participant Model as Model.find_by
122
+ participant Override as FindByOverride
123
+ participant Cache as Cache Store
124
+ participant DB as Database
125
+
126
+ App->>Model: Model.find_by(name: "example")
127
+ Model->>Override: Intercept find_by call
128
+
129
+ Override->>Override: Extract attributes from query
130
+ Override->>Override: Check cache_by_attributes config
131
+ Override->>Override: Generate cache key from attributes
132
+
133
+ alt Cache Hit
134
+ Override->>Cache: fetch(cache_key)
135
+ Cache-->>Override: Return cached record
136
+ Override-->>App: Return record
137
+ else Cache Miss
138
+ Override->>Cache: fetch(cache_key) with block
139
+ Cache->>DB: Execute original find_by query
140
+ DB-->>Cache: Return record from DB
141
+ Cache->>Cache: Store record with TTL
142
+ Cache-->>Override: Return record
143
+ Override-->>App: Return record
144
+ end
145
+ ```
146
+
147
+ ### 3. Cache Key Structure
148
+
149
+ ```mermaid
150
+ flowchart TD
151
+ Attrs["Query Attributes<br/>{name: 'active', type: 'primary'}"]
152
+
153
+ subgraph "Key Generation Process"
154
+ Sort["Sort attribute names<br/>['name', 'type']"]
155
+ CaseCheck["Apply case sensitivity<br/>name: 'active' → 'active'<br/>type: 'primary' → 'primary'"]
156
+ KeyGen["Generate cache key<br/>['ModelName', {name: 'active', type: 'primary'}]"]
157
+ end
158
+
159
+ Attrs --> Sort
160
+ Sort --> CaseCheck
161
+ CaseCheck --> KeyGen
162
+
163
+ KeyGen --> CacheStore["Cache Store<br/>Key: ['Status', {name: 'active', type: 'primary'}]<br/>Value: #&lt;Status id: 1, name: 'active'&gt;"]
164
+ ```
165
+
166
+ ### 4. Association Caching Flow
167
+
168
+ ```mermaid
169
+ sequenceDiagram
170
+ participant App as Application
171
+ participant Parent as Parent Model
172
+ participant Assoc as Association Reader
173
+ participant Cache as Cache Store
174
+ participant Child as Child Model
175
+ participant DB as Database
176
+
177
+ App->>Parent: parent.status
178
+ Parent->>Assoc: Call association reader
179
+
180
+ Assoc->>Assoc: Extract foreign key value
181
+ Assoc->>Assoc: Build cache key from foreign key
182
+
183
+ alt Cache Hit
184
+ Assoc->>Cache: fetch(cache_key)
185
+ Cache-->>Assoc: Return cached record
186
+ Assoc-->>App: Return associated record
187
+ else Cache Miss
188
+ Assoc->>Cache: fetch(cache_key) with block
189
+ Cache->>Child: Load association normally
190
+ Child->>DB: Query database
191
+ DB-->>Child: Return record
192
+ Child-->>Cache: Return record
193
+ Cache->>Cache: Store record with TTL
194
+ Cache-->>Assoc: Return record
195
+ Assoc-->>App: Return associated record
196
+ end
197
+ ```
198
+
199
+ ### 5. Cache Invalidation Strategy
200
+
201
+ ```mermaid
202
+ flowchart TD
203
+ subgraph "Record Lifecycle Events"
204
+ Create["Record Created"]
205
+ Update["Record Updated"]
206
+ Delete["Record Deleted"]
207
+ end
208
+
209
+ subgraph "Cache Invalidation Process"
210
+ Hook["after_commit callback"]
211
+ ExtractKeys["Extract all cacheable<br/>attribute combinations"]
212
+ BuildKeys["Build cache keys for<br/>before & after states"]
213
+ ClearCache["Delete cache entries"]
214
+ end
215
+
216
+ subgraph "Cache Keys Cleared"
217
+ BeforeKeys["Keys with old values"]
218
+ AfterKeys["Keys with new values"]
219
+ end
220
+
221
+ Create --> Hook
222
+ Update --> Hook
223
+ Delete --> Hook
224
+
225
+ Hook --> ExtractKeys
226
+ ExtractKeys --> BuildKeys
227
+ BuildKeys --> ClearCache
228
+
229
+ ClearCache --> BeforeKeys
230
+ ClearCache --> AfterKeys
231
+ ```
232
+
233
+ ### 6. Cache Implementation Types
234
+
235
+ ```mermaid
236
+ flowchart LR
237
+ subgraph "Cache Types"
238
+ subgraph "External Cache"
239
+ RC["Rails.cache<br/>(Redis/Memcached)"]
240
+ CC["Custom Cache Store"]
241
+ end
242
+
243
+ subgraph "In-Memory Cache"
244
+ MC["MemoryCache<br/>(Process-local)"]
245
+ end
246
+ end
247
+
248
+ subgraph "Configuration"
249
+ Global["Global Cache<br/>SupportTableCache.cache = store"]
250
+ PerClass["Per-Class Cache<br/>Model.support_table_cache = :memory"]
251
+ Testing["Test Mode<br/>SupportTableCache.testing!"]
252
+ end
253
+
254
+ Global --> RC
255
+ Global --> CC
256
+ PerClass --> MC
257
+ Testing --> MC
258
+
259
+ subgraph "Trade-offs"
260
+ ExtPros["✓ Shared across processes<br/>✓ Automatic invalidation<br/>✓ TTL support"]
261
+ ExtCons["✗ Network overhead<br/>✗ Serialization cost"]
262
+
263
+ MemPros["✓ Ultra-fast access<br/>✓ No network overhead<br/>✓ No serialization"]
264
+ MemCons["✗ Per-process storage<br/>✗ Manual invalidation<br/>✗ Memory usage"]
265
+ end
266
+
267
+ RC -.-> ExtPros
268
+ CC -.-> ExtPros
269
+ RC -.-> ExtCons
270
+ CC -.-> ExtCons
271
+
272
+ MC -.-> MemPros
273
+ MC -.-> MemCons
274
+ ```
275
+
276
+ ### 7. Testing Integration
277
+
278
+ ```mermaid
279
+ flowchart TD
280
+ subgraph "Test Execution"
281
+ TestStart["Test Begins"]
282
+ TestCode["Test Code Execution"]
283
+ TestEnd["Test Ends"]
284
+ end
285
+
286
+ subgraph "Cache Isolation"
287
+ TestCache["Isolated Test Cache<br/>(MemoryCache per test)"]
288
+ CleanSlate["Clean State<br/>(No cache pollution)"]
289
+ end
290
+
291
+ TestStart --> TestCache
292
+ TestCache --> TestCode
293
+ TestCode --> CleanSlate
294
+ CleanSlate --> TestEnd
295
+
296
+ subgraph "Integration Pattern"
297
+ RSpecWrap["RSpec around hook<br/>SupportTableCache.testing!"]
298
+ MiniTestWrap["MiniTest around hook<br/>SupportTableCache.testing!"]
299
+ end
300
+
301
+ RSpecWrap -.-> TestCache
302
+ MiniTestWrap -.-> TestCache
303
+ ```
304
+
305
+ ## Configuration Patterns
306
+
307
+ ### Model Setup
308
+
309
+ ```ruby
310
+ class Status < ApplicationRecord
311
+ include SupportTableCache
312
+
313
+ # Cache by single unique attribute
314
+ cache_by :name, case_sensitive: false
315
+
316
+ # Cache by composite unique key
317
+ cache_by [:group, :name]
318
+
319
+ # Cache by id (for associations)
320
+ cache_by :id
321
+
322
+ # Optional: Set TTL for cache entries
323
+ self.support_table_cache_ttl = 5.minutes
324
+
325
+ # Optional: Use in-memory cache
326
+ self.support_table_cache = :memory
327
+ end
328
+ ```
329
+
330
+ ### Association Setup
331
+
332
+ ```ruby
333
+ class Order < ApplicationRecord
334
+ include SupportTableCache::Associations
335
+
336
+ belongs_to :status
337
+ cache_belongs_to :status
338
+ end
339
+ ```
340
+
341
+ ## Performance Benefits
342
+
343
+ ### Query Elimination
344
+
345
+ ```mermaid
346
+ sequenceDiagram
347
+ participant App as Application
348
+ participant Cache as Cache
349
+ participant DB as Database
350
+
351
+ Note over App,DB: Without Cache
352
+ App->>DB: Status.find_by(name: 'active')
353
+ DB-->>App: Record
354
+ App->>DB: Status.find_by(name: 'active')
355
+ DB-->>App: Same Record (redundant query)
356
+ App->>DB: Status.find_by(name: 'active')
357
+ DB-->>App: Same Record (redundant query)
358
+
359
+ Note over App,DB: With Cache
360
+ App->>Cache: Status.find_by(name: 'active')
361
+ Cache->>DB: First query only
362
+ DB-->>Cache: Record
363
+ Cache-->>App: Record
364
+ App->>Cache: Status.find_by(name: 'active')
365
+ Cache-->>App: Record (from cache)
366
+ App->>Cache: Status.find_by(name: 'active')
367
+ Cache-->>App: Record (from cache)
368
+ ```
369
+
370
+ ## Design Principles
371
+
372
+ 1. **Transparent Integration**: No code changes required beyond configuration
373
+ 2. **Selective Caching**: Only caches queries that match configured unique keys
374
+ 3. **Automatic Invalidation**: Cache entries are cleared when records change
375
+ 4. **Flexible Cache Backends**: Supports various cache stores including in-memory
376
+ 5. **Test Isolation**: Provides testing utilities to prevent cache pollution
377
+ 6. **Performance Optimization**: Minimizes database queries for frequently accessed lookup data
378
+
379
+ ## Use Cases
380
+
381
+ - **Status/Type Tables**: Small enums stored in database tables
382
+ - **Configuration Tables**: Application settings and parameters
383
+ - **Reference Data**: Countries, states, categories, etc.
384
+ - **Lookup Tables**: Any small, rarely-changing reference data
385
+
386
+ This architecture enables significant performance improvements for applications that heavily query small support tables while maintaining data consistency and providing flexible caching options.
data/CHANGELOG.md CHANGED
@@ -4,24 +4,41 @@ 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.5
8
+
9
+ ### Fixed
10
+
11
+ - Replaced thread local variables with fiber local variables to prevent the possibility of behavior from leaking across fibers when disabling the cache in a block.
12
+ - Allow setting the cache to an in-memory cache by setting `support_table_cache` to `true`.
13
+
14
+ ## 1.1.4
15
+
16
+ ### Fixed
17
+
18
+ - Fixed issue where using `find_by` on a `has_many` relation would not take the scope of the relation into account when looking up the cached record. Now chaining a `find_by` onto a `has_many` relation will correctly bypass the cache and directly query the database.
19
+
7
20
  ## 1.1.3
8
21
 
9
22
  ### Fixed
23
+
10
24
  - Avoid calling methods that require a database connection when setting up belongs to caching.
11
25
 
12
26
  ## 1.1.2
13
27
 
14
28
  ### Fixed
29
+
15
30
  - Do not cache records where only some of the columns have been loaded with a call to `select`.
16
31
 
17
32
  ## 1.1.1
18
33
 
19
34
  ### Fixed
35
+
20
36
  - Fixed disabled and disable_cache methods to yield a block to match the documentation.
21
37
 
22
38
  ## 1.1.0
23
39
 
24
40
  ### Added
41
+
25
42
  - Added fetch_by and fetch_by! methods that can verify the result will be cacheable.
26
43
  - Allow configuring cache storage on a per class basis.
27
44
  - Allow disabling caching on per class basis.
@@ -30,15 +47,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
30
47
  - Added test mode to intialize new caches within a test block.
31
48
 
32
49
  ### Changed
50
+
33
51
  - Changed fiber local variables used for disabling the cache to thread local variables.
34
52
  - Using find_by! on a relation will now use the cache.
35
53
 
36
54
  ## 1.0.1
37
55
 
38
56
  ### Added
57
+
39
58
  - Preserve scope on relations terminated with a `find_by`.
40
59
 
41
60
  ## 1.0.0
42
61
 
43
62
  ### Added
63
+
44
64
  - Add SupportTableCache concern to enable automatic caching on models when calling `find_by` with unique key parameters.
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
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
+ [![Gem Version](https://badge.fury.io/rb/support_table_cache.svg)](https://badge.fury.io/rb/support_table_cache)
5
6
 
6
7
  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
8
 
@@ -18,6 +19,18 @@ end
18
19
 
19
20
  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
21
 
22
+ ## Table of Contents
23
+
24
+ - [Usage](#usage)
25
+ - [Setting the Cache](#setting-the-cache)
26
+ - [Disabling Caching](#disabling-caching)
27
+ - [Caching Belongs to Associations](#caching-belongs-to-associations)
28
+ - [Testing](#testing)
29
+ - [Companion Gems](#companion-gems)
30
+ - [Installation](#installation)
31
+ - [Contributing](#contributing)
32
+ - [License](#license)
33
+
21
34
  ## Usage
22
35
 
23
36
  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.
@@ -114,6 +127,9 @@ end
114
127
 
115
128
  You can include `SupportTableCache::Associations` in your `ApplicationRecord` class to make association caching available on all models.
116
129
 
130
+ > [!NOTE]
131
+ > You still need to set up the target model to cache by the primary key used by the belongs to association. Otherwise the association will not be cached.
132
+
117
133
  ### Testing
118
134
 
119
135
  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.
@@ -131,14 +147,16 @@ class MiniTest::Spec
131
147
  around do |tests|
132
148
  SupportTableCache.testing!(&tests)
133
149
  end
134
- =end
135
-
150
+ end
136
151
  ```
137
152
 
138
- ### Maintaining Data
153
+ ### Companion Gems
139
154
 
140
155
  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
156
 
157
+ > [!TIP]
158
+ > The [support_table](https://github.com/bdurand/support_table) gem combines both gems in a drop in solution for Rails applications.
159
+
142
160
  ## Installation
143
161
 
144
162
  Add this line to your application's Gemfile:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.3
1
+ 1.1.5
@@ -14,7 +14,7 @@ module SupportTableCache
14
14
  #
15
15
  # @param association_name [Symbol, String] The association name to cache.
16
16
  # @return [void]
17
- # @raise ArgumentError if the association is not defined or if it has a runtime scope.
17
+ # @raise [ArgumentError] if the association is not defined or if it has a runtime scope.
18
18
  def cache_belongs_to(association_name)
19
19
  reflection = reflections[association_name.to_s]
20
20
 
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportTableCache
4
+ # Utility class for managing fiber-local variables. This implementation
5
+ # does not pollute the global namespace.
6
+ class FiberLocals
7
+ def initialize
8
+ @mutex = Mutex.new
9
+ @locals = {}
10
+ end
11
+
12
+ def [](key)
13
+ fiber_locals = nil
14
+ @mutex.synchronize do
15
+ fiber_locals = @locals[Fiber.current.object_id]
16
+ end
17
+ return nil if fiber_locals.nil?
18
+
19
+ fiber_locals[key]
20
+ end
21
+
22
+ def with(key, value)
23
+ fiber_id = Fiber.current.object_id
24
+ fiber_locals = nil
25
+ previous_value = nil
26
+ inited_vars = false
27
+
28
+ begin
29
+ @mutex.synchronize do
30
+ fiber_locals = @locals[fiber_id]
31
+ if fiber_locals.nil?
32
+ fiber_locals = {}
33
+ @locals[fiber_id] = fiber_locals
34
+ inited_vars = true
35
+ end
36
+ end
37
+
38
+ previous_value = fiber_locals[key]
39
+ fiber_locals[key] = value
40
+
41
+ yield
42
+ ensure
43
+ if inited_vars
44
+ @mutex.synchronize do
45
+ @locals.delete(fiber_id)
46
+ end
47
+ else
48
+ fiber_locals[key] = previous_value
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -39,7 +39,8 @@ module SupportTableCache
39
39
  # Same as find_by, but performs a safety check to confirm the query will hit the cache.
40
40
  #
41
41
  # @param attributes [Hash] Attributes to find the record by.
42
- # @raise ArgumentError if the query cannot use the cache.
42
+ # @return [ActiveRecord::Base, nil] The found record or nil if not found.
43
+ # @raise [ArgumentError] if the query cannot use the cache.
43
44
  def fetch_by(attributes)
44
45
  find_by_attribute_names = support_table_find_by_attribute_names(attributes)
45
46
  unless support_table_cache_by_attributes.any? { |attribute_names, _ci, _where| attribute_names == find_by_attribute_names }
@@ -51,8 +52,9 @@ module SupportTableCache
51
52
  # Same as find_by!, but performs a safety check to confirm the query will hit the cache.
52
53
  #
53
54
  # @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.
55
+ # @return [ActiveRecord::Base] The found record.
56
+ # @raise [ArgumentError] if the query cannot use the cache.
57
+ # @raise [ActiveRecord::RecordNotFound] if no record is found.
56
58
  def fetch_by!(attributes)
57
59
  value = fetch_by(attributes)
58
60
  if value.nil?
@@ -8,11 +8,20 @@ module SupportTableCache
8
8
  # This cache will not store nil values. This is to prevent the cache from filling up with
9
9
  # cache misses because there is no purging mechanism.
10
10
  class MemoryCache
11
+ # Create a new memory cache.
12
+ #
13
+ # @return [SupportTableCache::MemoryCache]
11
14
  def initialize
12
15
  @cache = {}
13
16
  @mutex = Mutex.new
14
17
  end
15
18
 
19
+ # Fetch a value from the cache. If the key is not found or has expired, yields to get a new value.
20
+ #
21
+ # @param key [Object] The cache key.
22
+ # @param expires_in [Integer, nil] Time in seconds until the cached value expires.
23
+ # @yield Block to execute to get a new value if the key is not cached.
24
+ # @return [Object, nil] The cached value or the result of the block, or nil if no value is found.
16
25
  def fetch(key, expires_in: nil)
17
26
  serialized_value, expire_at = @cache[key]
18
27
  if serialized_value.nil? || (expire_at && expire_at < Process.clock_gettime(Process::CLOCK_MONOTONIC))
@@ -24,10 +33,20 @@ module SupportTableCache
24
33
  Marshal.load(serialized_value)
25
34
  end
26
35
 
36
+ # Read a value from the cache.
37
+ #
38
+ # @param key [Object] The cache key.
39
+ # @return [Object, nil] The cached value or nil if not found.
27
40
  def read(key)
28
41
  fetch(key)
29
42
  end
30
43
 
44
+ # Write a value to the cache.
45
+ #
46
+ # @param key [Object] The cache key.
47
+ # @param value [Object] The value to cache. Nil values are not cached.
48
+ # @param expires_in [Integer, nil] Time in seconds until the cached value expires.
49
+ # @return [void]
31
50
  def write(key, value, expires_in: nil)
32
51
  return if value.nil?
33
52
 
@@ -42,10 +61,17 @@ module SupportTableCache
42
61
  end
43
62
  end
44
63
 
64
+ # Delete a value from the cache.
65
+ #
66
+ # @param key [Object] The cache key.
67
+ # @return [void]
45
68
  def delete(key)
46
69
  @cache.delete(key)
47
70
  end
48
71
 
72
+ # Clear all values from the cache.
73
+ #
74
+ # @return [void]
49
75
  def clear
50
76
  @cache.clear
51
77
  end
@@ -5,12 +5,18 @@ module SupportTableCache
5
5
 
6
6
  module RelationOverride
7
7
  # Override for the find_by method that looks in the cache first.
8
+ #
9
+ # @param args [Array<Object>] Arguments passed to find_by.
10
+ # @return [ActiveRecord::Base, nil] The found record or nil if not found.
8
11
  def find_by(*args)
9
12
  return super unless klass.include?(SupportTableCache)
10
13
 
11
14
  cache = klass.send(:current_support_table_cache)
12
15
  return super unless cache
13
16
 
17
+ # Skip caching for has_many or has_many :through associations
18
+ return super if is_a?(ActiveRecord::Associations::CollectionProxy)
19
+
14
20
  return super if select_values.present?
15
21
 
16
22
  cache_key = nil
@@ -43,7 +49,10 @@ module SupportTableCache
43
49
  end
44
50
 
45
51
  # Override for the find_by! method that looks in the cache first.
46
- # @raise ActiveRecord::RecordNotFound if no record is found.
52
+ #
53
+ # @param args [Array<Object>] Arguments passed to find_by!.
54
+ # @return [ActiveRecord::Base] The found record.
55
+ # @raise [ActiveRecord::RecordNotFound] if no record is found.
47
56
  def find_by!(*args)
48
57
  value = find_by(*args)
49
58
  unless value
@@ -55,7 +64,8 @@ module SupportTableCache
55
64
  # Same as find_by, but performs a safety check to confirm the query will hit the cache.
56
65
  #
57
66
  # @param attributes [Hash] Attributes to find the record by.
58
- # @raise ArgumentError if the query cannot use the cache.
67
+ # @return [ActiveRecord::Base, nil] The found record or nil if not found.
68
+ # @raise [ArgumentError] if the query cannot use the cache.
59
69
  def fetch_by(attributes)
60
70
  find_by_attribute_names = support_table_find_by_attribute_names(attributes)
61
71
  unless klass.support_table_cache_by_attributes.any? { |attribute_names, _ci| attribute_names == find_by_attribute_names }
@@ -67,8 +77,9 @@ module SupportTableCache
67
77
  # Same as find_by!, but performs a safety check to confirm the query will hit the cache.
68
78
  #
69
79
  # @param attributes [Hash] Attributes to find the record by.
70
- # @raise ArgumentError if the query cannot use the cache.
71
- # @raise ActiveRecord::RecordNotFound if no record is found.
80
+ # @return [ActiveRecord::Base] The found record.
81
+ # @raise [ArgumentError] if the query cannot use the cache.
82
+ # @raise [ActiveRecord::RecordNotFound] if no record is found.
72
83
  def fetch_by!(attributes)
73
84
  value = fetch_by(attributes)
74
85
  if value.nil?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "support_table_cache/associations"
4
+ require_relative "support_table_cache/fiber_locals"
4
5
  require_relative "support_table_cache/find_by_override"
5
6
  require_relative "support_table_cache/relation_override"
6
7
  require_relative "support_table_cache/memory_cache"
@@ -10,6 +11,13 @@ require_relative "support_table_cache/memory_cache"
10
11
  module SupportTableCache
11
12
  extend ActiveSupport::Concern
12
13
 
14
+ NOT_SET = Object.new.freeze
15
+ private_constant :NOT_SET
16
+
17
+ @fiber_locals = FiberLocals.new
18
+ @cache = NOT_SET
19
+ @disabled = false
20
+
13
21
  included do
14
22
  # @api private Used to store the list of attribute names used for caching.
15
23
  class_attribute :support_table_cache_by_attributes, instance_accessor: false
@@ -39,22 +47,17 @@ module SupportTableCache
39
47
  # for a class will always take precedence over the global setting.
40
48
  #
41
49
  # @param disabled [Boolean] Caching will be disabled if this is true and enabled if false.
42
- # @yieldreturn The return value of the block.
50
+ # @yield Executes the provided block with caching disabled or enabled.
51
+ # @return [Object] The return value of the block.
43
52
  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
53
+ SupportTableCache.with_fiber_local("support_table_cache_disabled:#{name}", !!disabled, &block)
52
54
  end
53
55
 
54
56
  # Enable the caching behavior for this class within the block. The enabled setting
55
57
  # for a class will always take precedence over the global setting.
56
58
  #
57
- # @yieldreturn The return value of the block.
59
+ # @yield Executes the provided block with caching enabled.
60
+ # @return [Object] The return value of the block.
58
61
  def enable_cache(&block)
59
62
  disable_cache(false, &block)
60
63
  end
@@ -69,7 +72,7 @@ module SupportTableCache
69
72
 
70
73
  find_each do |record|
71
74
  support_table_cache_by_attributes.each do |attribute_names, case_sensitive|
72
- attributes = record.attributes.select { |name, value| attribute_names.include?(name) }
75
+ attributes = record.attributes.slice(*attribute_names)
73
76
  cache_key = SupportTableCache.cache_key(self, attributes, attribute_names, case_sensitive)
74
77
  cache.fetch(cache_key, expires_in: support_table_cache_ttl) { record }
75
78
  end
@@ -79,26 +82,26 @@ module SupportTableCache
79
82
  # Set a class-specific cache to use in lieu of the global cache.
80
83
  #
81
84
  # @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.
85
+ # specify the value :memory or true to use an optimized in-memory cache.
83
86
  # @return [void]
84
87
  def support_table_cache=(cache)
85
- cache = MemoryCache.new if cache == :memory
88
+ cache = MemoryCache.new if cache == :memory || cache == true
86
89
  self.support_table_cache_impl = cache
87
90
  end
88
91
 
89
92
  protected
90
93
 
91
94
  # Specify which attributes can be used for looking up records in the cache. Each value must
92
- # define a unique key, Multiple unique keys can be specified.
95
+ # define a unique key. Multiple unique keys can be specified.
93
96
  #
94
97
  # If multiple attributes are used to make up a unique key, then they should be passed in as an array.
95
98
  #
96
99
  # If you need to remove caching setup in a superclass, you can pass in the value false to reset
97
100
  # cache behavior on the class.
98
101
  #
99
- # @param attributes [String, Symbol, Array<String, Symbol>, FalseClass] Attributes that make up a unique key.
100
- # @param case_sensitive [Boolean] Indicate if strings should treated as case insensitive in the key.
101
- # @param where [Hash] A hash representing a hard coded set of attributes that must match a query in order
102
+ # @param attributes [String, Symbol, Array<String, Symbol>, false] Attributes that make up a unique key.
103
+ # @param case_sensitive [Boolean] Indicate if strings should be treated as case insensitive in the key.
104
+ # @param where [Hash, nil] A hash representing a hard coded set of attributes that must match a query in order
102
105
  # to cache the result. If a model has a default scope, then this value should be set to match the
103
106
  # where clause in that scope.
104
107
  # @return [void]
@@ -125,7 +128,7 @@ module SupportTableCache
125
128
  private
126
129
 
127
130
  def support_table_cache_disabled?
128
- current_block_value = Thread.current.thread_variable_get("support_table_cache_disabled:#{name}")
131
+ current_block_value = SupportTableCache.fiber_local_value("support_table_cache_disabled:#{name}")
129
132
  if current_block_value.nil?
130
133
  SupportTableCache.disabled?
131
134
  else
@@ -144,16 +147,11 @@ module SupportTableCache
144
147
  # disabled for that block. If no block is specified, then caching is disabled globally.
145
148
  #
146
149
  # @param disabled [Boolean] Caching will be disabled if this is true and enabled if false.
147
- # @yieldreturn The return value of the block.
150
+ # @yield Executes the provided block with caching disabled or enabled (if block is given).
151
+ # @return [Object, nil] The return value of the block if a block is given, nil otherwise.
148
152
  def disable(disabled = true, &block)
149
153
  if block
150
- save_val = Thread.current.thread_variable_get(:support_table_cache_disabled)
151
- begin
152
- Thread.current.thread_variable_set(:support_table_cache_disabled, !!disabled)
153
- yield
154
- ensure
155
- Thread.current.thread_variable_set(:support_table_cache_disabled, save_val)
156
- end
154
+ SupportTableCache.with_fiber_local("support_table_cache_disabled", !!disabled, &block)
157
155
  else
158
156
  @disabled = !!disabled
159
157
  end
@@ -162,7 +160,8 @@ module SupportTableCache
162
160
  # Enable the caching behavior for all classes. If a block is specified, then caching is only
163
161
  # enabled for that block. If no block is specified, then caching is enabled globally.
164
162
  #
165
- # @yieldreturn The return value of the block.
163
+ # @yield Executes the provided block with caching enabled (if block is given).
164
+ # @return [Object, nil] The return value of the block if a block is given, nil otherwise.
166
165
  def enable(&block)
167
166
  disable(false, &block)
168
167
  end
@@ -170,9 +169,9 @@ module SupportTableCache
170
169
  # Return true if caching has been disabled.
171
170
  # @return [Boolean]
172
171
  def disabled?
173
- block_value = Thread.current.thread_variable_get(:support_table_cache_disabled)
172
+ block_value = SupportTableCache.fiber_local_value("support_table_cache_disabled")
174
173
  if block_value.nil?
175
- !!(defined?(@disabled) && @disabled)
174
+ !!@disabled
176
175
  else
177
176
  block_value
178
177
  end
@@ -193,7 +192,7 @@ module SupportTableCache
193
192
  def cache
194
193
  if testing_cache
195
194
  testing_cache
196
- elsif defined?(@cache)
195
+ elsif @cache != NOT_SET
197
196
  @cache
198
197
  elsif defined?(Rails.cache)
199
198
  Rails.cache
@@ -204,27 +203,25 @@ module SupportTableCache
204
203
  # can use this to wrap your test methods so that cached values from one test don't show up
205
204
  # in subsequent tests.
206
205
  #
207
- # @return [void]
206
+ # @yield Executes the provided block in test mode.
207
+ # @return [Object] The return value of the block.
208
208
  def testing!(&block)
209
- save_val = Thread.current.thread_variable_get(:support_table_cache_test_cache)
209
+ save_val = SupportTableCache.fiber_local_value("support_table_cache_test_cache")
210
210
  if save_val.nil?
211
- Thread.current.thread_variable_set(:support_table_cache_test_cache, MemoryCache.new)
212
- end
213
- begin
211
+ SupportTableCache.with_fiber_local("support_table_cache_test_cache", MemoryCache.new, &block)
212
+ else
214
213
  yield
215
- ensure
216
- Thread.current.thread_variable_set(:support_table_cache_test_cache, save_val)
217
214
  end
218
215
  end
219
216
 
220
217
  # Get the current test mode cache. This will only return a value inside of a `testing!` block.
221
218
  #
222
- # @return [SupportTableCache::MemoryCache]
219
+ # @return [SupportTableCache::MemoryCache, nil] The test cache or nil if not in test mode.
223
220
  # @api private
224
221
  def testing_cache
225
- unless defined?(@cache) && @cache.nil?
226
- Thread.current.thread_variable_get(:support_table_cache_test_cache)
227
- end
222
+ return nil if @cache.nil?
223
+
224
+ SupportTableCache.fiber_local_value("support_table_cache_test_cache")
228
225
  end
229
226
 
230
227
  # Generate a consistent cache key for a set of attributes. It will return nil if the attributes
@@ -232,9 +229,9 @@ module SupportTableCache
232
229
  #
233
230
  # @param klass [Class] The class that is being cached.
234
231
  # @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.
232
+ # @param key_attribute_names [Array<String>] List of attributes that can be used as a key in the cache.
236
233
  # @param case_sensitive [Boolean] Indicator if string values are case-sensitive in the cache key.
237
- # @return [String]
234
+ # @return [Array(String, Hash), nil] A two-element array with the class name and attributes hash, or nil if not cacheable.
238
235
  # @api private
239
236
  def cache_key(klass, attributes, key_attribute_names, case_sensitive)
240
237
  return nil if attributes.blank? || key_attribute_names.blank?
@@ -253,6 +250,14 @@ module SupportTableCache
253
250
 
254
251
  [klass.name, sorted_attributes]
255
252
  end
253
+
254
+ def fiber_local_value(varname)
255
+ @fiber_locals[varname]
256
+ end
257
+
258
+ def with_fiber_local(varname, value, &block)
259
+ @fiber_locals.with(varname, value, &block)
260
+ end
256
261
  end
257
262
 
258
263
  # Remove the cache entry for this record.
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: support_table_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.3
4
+ version: 1.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2023-01-23 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -38,19 +37,21 @@ dependencies:
38
37
  - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
41
- description:
42
40
  email:
43
41
  - bbdurand@gmail.com
44
42
  executables: []
45
43
  extensions: []
46
44
  extra_rdoc_files: []
47
45
  files:
46
+ - AGENTS.md
47
+ - ARCHITECTURE.md
48
48
  - CHANGELOG.md
49
49
  - MIT-LICENSE
50
50
  - README.md
51
51
  - VERSION
52
52
  - lib/support_table_cache.rb
53
53
  - lib/support_table_cache/associations.rb
54
+ - lib/support_table_cache/fiber_locals.rb
54
55
  - lib/support_table_cache/find_by_override.rb
55
56
  - lib/support_table_cache/memory_cache.rb
56
57
  - lib/support_table_cache/relation_override.rb
@@ -59,7 +60,6 @@ homepage: https://github.com/bdurand/support_table_cache
59
60
  licenses:
60
61
  - MIT
61
62
  metadata: {}
62
- post_install_message:
63
63
  rdoc_options: []
64
64
  require_paths:
65
65
  - lib
@@ -74,8 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
74
  - !ruby/object:Gem::Version
75
75
  version: '0'
76
76
  requirements: []
77
- rubygems_version: 3.0.3
78
- signing_key:
77
+ rubygems_version: 4.0.3
79
78
  specification_version: 4
80
79
  summary: Automatic ActiveRecord caching for small support tables.
81
80
  test_files: []