Skip to content

Commit 2f88816

Browse files
agrbergclaude
andcommitted
Forward keyword arguments and blocks through cached methods
Generated methods only accepted *args, silently dropping keyword arguments and blocks. This caused methods with kwargs to break when wrapped with `cacheable`. Changes: - All generated methods now accept *args, **kwargs, &block - key_format procs receive kwargs as well - unless proc is resolved once at definition time instead of per-call - Use Bundler.setup instead of Bundler.require in spec_helper for Ruby 4.0 compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8fa5a13 commit 2f88816

6 files changed

Lines changed: 152 additions & 69 deletions

File tree

README.md

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

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

examples/custom_key_example.rb

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

8-
cacheable :star_count, key_format: ->(target, method_name, method_args) do
9-
[target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/')
8+
cacheable :star_count, 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']
@@ -19,12 +20,12 @@ def star_count(repo)
1920

2021
a = GitHubApiAdapter.new
2122
a.star_count('cacheable')
22-
# Fetching data from GitHub for cacheable
23-
# => 19
23+
# Fetching data from GitHub for cacheable (as of 2026-02-26)
24+
# => 58
2425
a.star_count('cacheable')
25-
# => 19
26+
# => 58
2627
a.star_count('tokenautocomplete')
27-
# Fetching data from GitHub for tokenautocomplete
28-
# => 1164
28+
# Fetching data from GitHub for tokenautocomplete (as of 2026-02-26)
29+
# => 1309
2930
a.star_count('tokenautocomplete')
30-
# => 1164
31+
# => 1309

lib/cacheable/method_generator.rb

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,41 +20,40 @@ def create_cacheable_methods(original_method_name, opts = {})
2020
method_names = create_method_names(original_method_name)
2121
key_format_proc = opts[:key_format] || default_key_format
2222

23+
unless_proc = opts[:unless].is_a?(Symbol) ? opts[:unless].to_proc : opts[:unless]
24+
2325
const_get(method_interceptor_module_name).class_eval do
24-
define_method(method_names[:key_format_method_name]) do |*args|
25-
key_format_proc.call(self, original_method_name, args)
26+
define_method(method_names[:key_format_method_name]) do |*args, **kwargs|
27+
key_format_proc.call(self, original_method_name, args, **kwargs)
2628
end
2729

28-
define_method(method_names[:clear_cache_method_name]) do |*args|
29-
Cacheable.cache_adapter.delete(__send__(method_names[:key_format_method_name], *args))
30+
define_method(method_names[:clear_cache_method_name]) do |*args, **kwargs|
31+
Cacheable.cache_adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs))
3032
end
3133

32-
define_method(method_names[:without_cache_method_name]) do |*args|
33-
original_method = method(original_method_name).super_method
34-
original_method.call(*args)
34+
define_method(method_names[:without_cache_method_name]) do |*args, **kwargs, &block|
35+
method(original_method_name).super_method.call(*args, **kwargs, &block)
3536
end
3637

37-
define_method(method_names[:with_cache_method_name]) do |*args|
38-
Cacheable.cache_adapter.fetch(__send__(method_names[:key_format_method_name], *args), opts[:cache_options]) do # rubocop:disable Lint/UselessDefaultValueArgument -- not Hash#fetch; second arg is cache options (e.g. expires_in) passed to the adapter
39-
__send__(method_names[:without_cache_method_name], *args)
38+
define_method(method_names[:with_cache_method_name]) do |*args, **kwargs, &block|
39+
Cacheable.cache_adapter.fetch(__send__(method_names[:key_format_method_name], *args, **kwargs), opts[:cache_options]) do # rubocop:disable Lint/UselessDefaultValueArgument -- not Hash#fetch; second arg is cache options (e.g. expires_in) passed to the adapter
40+
__send__(method_names[:without_cache_method_name], *args, **kwargs, &block)
4041
end
4142
end
4243

43-
define_method(original_method_name) do |*args|
44-
unless_proc = opts[:unless].is_a?(Symbol) ? opts[:unless].to_proc : opts[:unless]
45-
46-
if unless_proc&.call(self, original_method_name, args)
47-
__send__(method_names[:without_cache_method_name], *args)
44+
define_method(original_method_name) do |*args, **kwargs, &block|
45+
if unless_proc&.call(self, original_method_name, args, **kwargs)
46+
__send__(method_names[:without_cache_method_name], *args, **kwargs, &block)
4847
else
49-
__send__(method_names[:with_cache_method_name], *args)
48+
__send__(method_names[:with_cache_method_name], *args, **kwargs, &block)
5049
end
5150
end
5251
end
5352
end
5453
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
5554

5655
def default_key_format
57-
proc do |target, method_name, _method_args|
56+
proc do |target, method_name, _method_args, **_kwargs|
5857
# By default, we omit the _method_args from the cache key because there is no acceptable default behavior
5958
class_name = (target.is_a?(Module) ? target.name : target.class.name)
6059
cache_key = target.respond_to?(:cache_key) ? target.cache_key : class_name

spec/cacheable/cacheable_spec.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,73 @@
131131
end
132132
end
133133

134+
describe 'keyword arguments' do
135+
it 'forwards keyword arguments to the original method' do
136+
cacheable_class.class_eval do
137+
define_method(:method_with_kwargs) do |name:, greeting: 'Hello'|
138+
"#{greeting}, #{name}"
139+
end
140+
141+
cacheable :method_with_kwargs, key_format: proc { |_, _, args, **kwargs| [args, kwargs] }
142+
end
143+
144+
expect(cacheable_object.method_with_kwargs(name: 'World')).to eq('Hello, World')
145+
expect(cacheable_object.method_with_kwargs(name: 'World', greeting: 'Hi')).to eq('Hi, World')
146+
end
147+
148+
it 'forwards keyword arguments when skipping cache' do
149+
cacheable_class.class_eval do
150+
define_method(:kwargs_no_cache) do |val:|
151+
val
152+
end
153+
154+
cacheable :kwargs_no_cache, key_format: proc { |_, _, args, **kwargs| [args, kwargs] }
155+
end
156+
157+
expect(cacheable_object.kwargs_no_cache_without_cache(val: 42)).to eq(42)
158+
end
159+
160+
it 'forwards mixed positional and keyword arguments' do
161+
cacheable_class.class_eval do
162+
define_method(:mixed_args) do |pos, key:|
163+
"#{pos}-#{key}"
164+
end
165+
166+
cacheable :mixed_args, key_format: proc { |_, _, args, **kwargs| [args, kwargs] }
167+
end
168+
169+
expect(cacheable_object.mixed_args('a', key: 'b')).to eq('a-b')
170+
end
171+
end
172+
173+
describe 'block forwarding' do
174+
it 'forwards blocks to the original method on cache miss' do
175+
cacheable_class.class_eval do
176+
define_method(:method_with_block) do |&block|
177+
block.call('from cache miss')
178+
end
179+
180+
cacheable :method_with_block
181+
end
182+
183+
result = cacheable_object.method_with_block { |msg| "got: #{msg}" }
184+
expect(result).to eq('got: from cache miss')
185+
end
186+
187+
it 'forwards blocks when skipping cache' do
188+
cacheable_class.class_eval do
189+
define_method(:block_no_cache) do |&block|
190+
block.call('direct')
191+
end
192+
193+
cacheable :block_no_cache
194+
end
195+
196+
result = cacheable_object.block_no_cache_without_cache { |msg| "got: #{msg}" }
197+
expect(result).to eq('got: direct')
198+
end
199+
end
200+
134201
describe 'interceptor module' do
135202
it 'has the public generated methods' do
136203
expect(cacheable_class.ancestors.first.instance_methods(false)).to include(cacheable_method, :"#{cacheable_method}_without_cache", :"#{cacheable_method}_with_cache", :"#{cacheable_method}_key_format")
@@ -434,6 +501,20 @@ def cache_control_method
434501
expect(cacheable_object).to receive(inner_method).twice.and_call_original
435502
2.times { cacheable_object.send(cache_depends_on_args, the_method_arg) }
436503
end
504+
505+
it 'passes kwargs to the `unless` proc' do
506+
cache_depends_on_kwargs = :cache_depends_on_kwargs
507+
inner_method = cacheable_method_inner
508+
cacheable_class.class_eval do
509+
define_method(cache_depends_on_kwargs) do |force: false| # rubocop:disable Lint/UnusedBlockArgument
510+
send inner_method
511+
end
512+
513+
cacheable cache_depends_on_kwargs, unless: proc { |_, _, _, force: false| force }
514+
end
515+
expect(cacheable_object).to receive(inner_method).twice.and_call_original
516+
2.times { cacheable_object.send(cache_depends_on_kwargs, force: true) }
517+
end
437518
end
438519

439520
describe 'on class methods' do

spec/spec_helper.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
require 'bundler'
2-
Bundler.require(:test)
3-
1+
require 'bundler/setup'
42
require 'cacheable'
53

64
# This file was generated by the `rspec --init` command. Conventionally, all

0 commit comments

Comments
 (0)