Skip to content

Commit 40aed1e

Browse files
authored
Merge pull request #21 from splitwise/ar/hangin_with_claude
Cacheable feature improvements and documentation updates
2 parents 098b0b5 + 4eb4a46 commit 40aed1e

File tree

15 files changed

+381
-141
lines changed

15 files changed

+381
-141
lines changed

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ plugins:
33

44
AllCops:
55
NewCops: enable
6+
SuggestExtensions: false # rubocop-rake is the only suggestion and our Rakefile is trivial boilerplate
67
TargetRubyVersion: 3.3
78

89
Layout/LineLength:

CLAUDE.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project
6+
7+
Cacheable is a Ruby gem by Splitwise that adds method caching via an AOP (Aspect-Oriented Programming) pattern. Include `Cacheable` in a class, annotate methods with `cacheable :method_name`, and results are automatically cached.
8+
9+
## Commands
10+
11+
```bash
12+
# Install dependencies
13+
bundle install
14+
15+
# Run full default task (rubocop + rspec)
16+
bundle exec rake
17+
18+
# Run tests only
19+
bundle exec rspec
20+
21+
# Run a single test file
22+
bundle exec rspec spec/cacheable/cacheable_spec.rb
23+
24+
# Run a single test by line number
25+
bundle exec rspec spec/cacheable/cacheable_spec.rb:45
26+
27+
# Run linter only
28+
bundle exec rubocop
29+
30+
# Auto-fix lint issues
31+
bundle exec rubocop -a
32+
33+
# Watch and auto-run tests/lint on file changes
34+
bundle exec guard
35+
```
36+
37+
## Architecture
38+
39+
The gem uses **module prepending with dynamic method generation** to intercept and cache method calls.
40+
41+
### Core flow
42+
43+
1. **`Cacheable`** (`lib/cacheable.rb`) — The module users include. On `included`, it extends the host class with `MethodGenerator` and creates a unique anonymous interceptor module that gets prepended to the class.
44+
45+
2. **`MethodGenerator`** (`lib/cacheable/method_generator.rb`) — When `cacheable :method_name` is called, this generates five methods on the interceptor module:
46+
- `method_name` (override) — dispatcher that routes to `with_cache` or `without_cache` based on the `unless:` condition
47+
- `method_with_cache` — fetch from cache or compute and store
48+
- `method_without_cache` — bypass cache, call original
49+
- `method_key_format` — generate the cache key
50+
- `clear_method_cache` — invalidate the cache entry
51+
52+
3. **`CacheAdapter`** (`lib/cacheable/cache_adapter.rb`) — Protocol for cache backends. Default is `:memory`. Required interface: `fetch(key, options, &block)` and `delete(key)`.
53+
54+
4. **`MemoryAdapter`** (`lib/cacheable/cache_adapters/memory_adapter.rb`) — Built-in hash-backed in-memory cache. Production use typically wires in `Rails.cache` or a custom adapter.
55+
56+
### Key design details
57+
58+
- Each class that includes `Cacheable` gets its own unique interceptor module (created via `Module.new`), which is prepended to the class. This is how `super` chains through to the original method.
59+
- The `unless:` option accepts a proc/symbol that, when truthy, skips caching and calls the original method directly.
60+
- `key_format:` accepts a proc receiving `(target, method_name, args, **kwargs)` for custom cache key generation.
61+
62+
## Style
63+
64+
- Max line length: 120 characters
65+
- Max method length: 25 lines
66+
- Rubocop enforced with `NewCops: enable`
67+
- No frozen string literal comments required

Gemfile.lock

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
cacheable (2.0.0)
4+
cacheable (2.1.0)
55

66
GEM
77
remote: https://rubygems.org/
@@ -14,9 +14,7 @@ GEM
1414
reline (>= 0.6.0)
1515
coderay (1.1.3)
1616
diff-lcs (1.6.2)
17-
ffi (1.17.3-arm64-darwin)
18-
ffi (1.17.3-x86_64-darwin)
19-
ffi (1.17.3-x86_64-linux-gnu)
17+
ffi (1.17.3)
2018
formatador (1.2.3)
2119
reline
2220
guard (2.20.1)
@@ -118,10 +116,7 @@ GEM
118116
unicode-emoji (4.2.0)
119117

120118
PLATFORMS
121-
arm64-darwin-23
122-
x86_64-darwin-20
123-
x86_64-darwin-21
124-
x86_64-linux
119+
ruby
125120

126121
DEPENDENCIES
127122
cacheable!

README.md

Lines changed: 63 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ end
8282
> a = GitHubApiAdapter.new
8383
> a.star_count
8484
Fetching data from GitHub
85-
=> 19
85+
=> 58
8686
> a.star_count
87-
=> 19
87+
=> 58
8888
8989
# Notice that "Fetching data from GitHub" was not output the 2nd time the method was invoked.
9090
# The network call and result parsing would also not be performed again.
@@ -102,12 +102,12 @@ The cache can intentionally be skipped by appending `_without_cache` to the meth
102102
> a = GitHubApiAdapter.new
103103
> a.star_count
104104
Fetching data from GitHub
105-
=> 19
105+
=> 58
106106
> a.star_count_without_cache
107107
Fetching data from GitHub
108-
=> 19
108+
=> 58
109109
> a.star_count
110-
=> 19
110+
=> 58
111111
```
112112

113113
#### Remove the Value via `clear_#{method}_cache`
@@ -118,15 +118,15 @@ The cached value can be cleared at any time by calling `clear_#{your_method_name
118118
> a = GitHubApiAdapter.new
119119
> a.star_count
120120
Fetching data from GitHub
121-
=> 19
121+
=> 58
122122
> a.star_count
123-
=> 19
123+
=> 58
124124
125125
> a.clear_star_count_cache
126126
=> true
127127
> a.star_count
128128
Fetching data from GitHub
129-
=> 19
129+
=> 58
130130
```
131131

132132
## Additional Configuration
@@ -135,7 +135,7 @@ Fetching data from GitHub
135135

136136
#### Default
137137

138-
By default, Cacheable will construct a key in the format `[cache_key || class_name, method_name]` without using method arguments.
138+
By default, Cacheable will construct a key in the format `[cache_key || class_name, method_name]` without using method arguments. If a cached method is called with arguments while using the default key format, Cacheable will emit a warning to stderr since different arguments will return the same cached value. To silence the warning, provide a `:key_format` proc that includes the arguments in the cache key.
139139

140140
If the object responds to `cache_key` its return value will be the first element in the array. `ActiveRecord` provides [`cache_key`](https://api.rubyonrails.org/classes/ActiveRecord/Integration.html#method-i-cache_key) but it can be added to any Ruby object or overwritten. If the object does not respond to it, the name of the class will be used instead. The second element will be the name of the method as a symbol.
141141

@@ -155,12 +155,13 @@ require 'net/http'
155155
class GitHubApiAdapter
156156
include Cacheable
157157

158-
cacheable :star_count, key_format: ->(target, method_name, method_args) do
159-
[target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/')
158+
cacheable :star_count, key_format: ->(target, method_name, method_args, **kwargs) do
159+
date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))
160+
[target.class, method_name, method_args.first, date].join('/')
160161
end
161162

162-
def star_count(repo)
163-
puts "Fetching data from GitHub for #{repo}"
163+
def star_count(repo, date: Time.now.strftime('%Y-%m-%d'))
164+
puts "Fetching data from GitHub for #{repo} (as of #{date})"
164165
url = "https://api.github.com/repos/splitwise/#{repo}"
165166

166167
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
@@ -170,33 +171,34 @@ end
170171

171172
* `target` is the object the method is being called on (`#<GitHubApiAdapter:0x0…0>`)
172173
* `method_name` is the name of the method being cached (`:star_count`)
173-
* `method_args` is an array of arguments being passed to the method (`[params]`)
174+
* `method_args` is an array of positional arguments being passed to the method (`[params]`)
175+
* `**kwargs` are the keyword arguments being passed to the method
174176

175177
Including the method argument(s) allows you to cache different calls to the same method. Without the arguments in the cache key, a call to `star_count('cacheable')` would populate the cache and `star_count('tokenautocomplete')` would return the number of stars for Cacheable instead of what you want.
176178

177-
In addition, we're including the current date in the cache key so calling this method tomorrow will return an updated value.
179+
**Note:** The `key_format` proc only receives keyword arguments that the caller explicitly passes — method defaults are not included. That's why the proc uses `kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))` to compute its own default when `date:` is omitted. This ensures the cache key always varies by date.
178180

179181
```irb
180182
> a = GitHubApiAdapter.new
181183
> a.star_count('cacheable')
182-
Fetching data from GitHub for cacheable
183-
=> 19
184+
Fetching data from GitHub for cacheable (as of 2026-02-26)
185+
=> 58
184186
> a.star_count('cacheable')
185-
=> 19
187+
=> 58
186188
> a.star_count('tokenautocomplete')
187-
Fetching data from GitHub for tokenautocomplete
188-
=> 1164
189+
Fetching data from GitHub for tokenautocomplete (as of 2026-02-26)
190+
=> 1309
189191
> a.star_count('tokenautocomplete')
190-
=> 1164
192+
=> 1309
191193
192194
# In this example the follow cache keys are generated:
193-
# GitHubApiAdapter/star_count/cacheable/2018-09-21
194-
# GitHubApiAdapter/star_count/tokenautocomplete/2018-09-21
195+
# GitHubApiAdapter/star_count/cacheable/2026-02-26
196+
# GitHubApiAdapter/star_count/tokenautocomplete/2026-02-26
195197
```
196198

197199
### Conditional Caching
198200

199-
You can control if a method should be cached by supplying a proc to the `unless:` option which will get the same arguments as `key_format:`. This logic can be defined in a method on the class and the name of the method as a symbol can be passed as well. **Note**: When using a symbol, the first argument, `target`, will not be passed but will be available as `self`.
201+
You can control if a method should be cached by supplying a proc to the `unless:` option which will get the same arguments as `key_format:` (`target, method_name, method_args, **kwargs`). This logic can be defined in a method on the class and the name of the method as a symbol can be passed as well. **Note**: When using a symbol, the first argument, `target`, will not be passed but will be available as `self`.
200202

201203
```ruby
202204
# From examples/conditional_example.rb
@@ -208,18 +210,19 @@ require 'net/http'
208210
class GitHubApiAdapter
209211
include Cacheable
210212

211-
cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do
212-
[target.class, method_name, method_args.first].join('/')
213+
cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args, **kwargs) do
214+
date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))
215+
[target.class, method_name, method_args.first, date].join('/')
213216
end
214217

215-
def star_count(repo)
216-
puts "Fetching data from GitHub for #{repo}"
218+
def star_count(repo, date: Time.now.strftime('%Y-%m-%d'))
219+
puts "Fetching data from GitHub for #{repo} (as of #{date})"
217220
url = "https://api.github.com/repos/splitwise/#{repo}"
218221

219222
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
220223
end
221224

222-
def growing_fast?(_method_name, method_args)
225+
def growing_fast?(_method_name, method_args, **)
223226
method_args.first == 'cacheable'
224227
end
225228
end
@@ -230,17 +233,17 @@ Cacheable is new so we don't want to cache the number of stars it has as we expe
230233
```irb
231234
> a = GitHubApiAdapter.new
232235
> a.star_count('tokenautocomplete')
233-
Fetching data from GitHub for tokenautocomplete
234-
=> 1164
236+
Fetching data from GitHub for tokenautocomplete (as of 2026-02-26)
237+
=> 1309
235238
a.star_count('tokenautocomplete')
236-
=> 1164
239+
=> 1309
237240
238241
> a.star_count('cacheable')
239-
Fetching data from GitHub for cacheable
240-
=> 19
242+
Fetching data from GitHub for cacheable (as of 2026-02-26)
243+
=> 58
241244
> a.star_count('cacheable')
242-
Fetching data from GitHub for cacheable
243-
=> 19
245+
Fetching data from GitHub for cacheable (as of 2026-02-26)
246+
=> 58
244247
```
245248

246249
### Cache Options
@@ -251,6 +254,26 @@ If your cache backend supports options, you can pass them as the `cache_options:
251254
cacheable :with_options, cache_options: {expires_in: 3_600}
252255
```
253256

257+
### Per-Class Cache Adapter
258+
259+
By default, all classes use the global adapter set via `Cacheable.cache_adapter`. If you need a specific class to use a different cache backend, you can set one directly on the class:
260+
261+
```ruby
262+
class FrequentlyAccessedModel
263+
include Cacheable
264+
265+
self.cache_adapter = MyFasterCache.new
266+
267+
cacheable :expensive_lookup
268+
269+
def expensive_lookup
270+
# ...
271+
end
272+
end
273+
```
274+
275+
The class-level adapter takes precedence over the global adapter. Classes without their own adapter fall back to `Cacheable.cache_adapter` as usual.
276+
254277
### Flexible Options
255278

256279
You can use the same options with multiple cache methods or limit them only to specific methods:
@@ -302,15 +325,15 @@ end
302325
```irb
303326
> GitHubApiAdapter.star_count_for_cacheable
304327
Fetching data from GitHub for cacheable
305-
=> 19
328+
=> 58
306329
> GitHubApiAdapter.star_count_for_cacheable
307-
=> 19
330+
=> 58
308331
309332
> GitHubApiAdapter.star_count_for_tokenautocomplete
310333
Fetching data from GitHub for tokenautocomplete
311-
=> 1164
334+
=> 1309
312335
> GitHubApiAdapter.star_count_for_tokenautocomplete
313-
=> 1164
336+
=> 1309
314337
```
315338

316339
### Other Notes / Frequently Asked Questions

examples/class_method_example.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ def self.star_count_for_tokenautocomplete
2929

3030
GitHubApiAdapter.star_count_for_cacheable
3131
# Fetching data from GitHub for cacheable
32-
# => 19
32+
# => 58
3333
GitHubApiAdapter.star_count_for_cacheable
34-
# => 19
34+
# => 58
3535

3636
GitHubApiAdapter.star_count_for_tokenautocomplete
3737
# Fetching data from GitHub for tokenautocomplete
38-
# => 1164
38+
# => 1309
3939
GitHubApiAdapter.star_count_for_tokenautocomplete
40-
# => 1164
40+
# => 1309

examples/conditional_example.rb

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,33 @@
55
class GitHubApiAdapter
66
include Cacheable
77

8-
cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do
9-
[target.class, method_name, method_args.first].join('/')
8+
cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args, **kwargs) do
9+
date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))
10+
[target.class, method_name, method_args.first, date].join('/')
1011
end
1112

12-
def star_count(repo)
13-
puts "Fetching data from GitHub for #{repo}"
13+
def star_count(repo, date: Time.now.strftime('%Y-%m-%d'))
14+
puts "Fetching data from GitHub for #{repo} (as of #{date})"
1415
url = "https://api.github.com/repos/splitwise/#{repo}"
1516

1617
JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
1718
end
1819

19-
def growing_fast?(_method_name, method_args)
20+
def growing_fast?(_method_name, method_args, **)
2021
method_args.first == 'cacheable'
2122
end
2223
end
2324

2425
a = GitHubApiAdapter.new
2526
a.star_count('tokenautocomplete')
26-
# Fetching data from GitHub for tokenautocomplete
27-
# => 1164
27+
# Fetching data from GitHub for tokenautocomplete (as of 2026-02-26)
28+
# => 1309
2829
a.star_count('tokenautocomplete')
29-
# => 1164
30+
# => 1309
3031

3132
a.star_count('cacheable')
32-
# Fetching data from GitHub for cacheable
33-
# => 19
33+
# Fetching data from GitHub for cacheable (as of 2026-02-26)
34+
# => 58
3435
a.star_count('cacheable')
35-
# Fetching data from GitHub for cacheable
36-
# => 19
36+
# Fetching data from GitHub for cacheable (as of 2026-02-26)
37+
# => 58

0 commit comments

Comments
 (0)