diff --git a/.rubocop.yml b/.rubocop.yml index 79bbe90..518efe9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,7 @@ plugins: AllCops: NewCops: enable + SuggestExtensions: false # rubocop-rake is the only suggestion and our Rakefile is trivial boilerplate TargetRubyVersion: 3.3 Layout/LineLength: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1b5b52f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +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. + +## Commands + +```bash +# Install dependencies +bundle install + +# Run full default task (rubocop + rspec) +bundle exec rake + +# Run tests only +bundle exec rspec + +# Run a single test file +bundle exec rspec spec/cacheable/cacheable_spec.rb + +# Run a single test by line number +bundle exec rspec spec/cacheable/cacheable_spec.rb:45 + +# Run linter only +bundle exec rubocop + +# Auto-fix lint issues +bundle exec rubocop -a + +# Watch and auto-run tests/lint on file changes +bundle exec guard +``` + +## Architecture + +The gem uses **module prepending with dynamic method generation** to intercept and cache method calls. + +### Core flow + +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. + +2. **`MethodGenerator`** (`lib/cacheable/method_generator.rb`) — When `cacheable :method_name` is called, this generates five methods on the interceptor module: + - `method_name` (override) — dispatcher that routes to `with_cache` or `without_cache` based on the `unless:` condition + - `method_with_cache` — fetch from cache or compute and store + - `method_without_cache` — bypass cache, call original + - `method_key_format` — generate the cache key + - `clear_method_cache` — invalidate the cache entry + +3. **`CacheAdapter`** (`lib/cacheable/cache_adapter.rb`) — Protocol for cache backends. Default is `:memory`. Required interface: `fetch(key, options, &block)` and `delete(key)`. + +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. + +### Key design details + +- 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. +- The `unless:` option accepts a proc/symbol that, when truthy, skips caching and calls the original method directly. +- `key_format:` accepts a proc receiving `(target, method_name, args, **kwargs)` for custom cache key generation. + +## Style + +- Max line length: 120 characters +- Max method length: 25 lines +- Rubocop enforced with `NewCops: enable` +- No frozen string literal comments required diff --git a/Gemfile.lock b/Gemfile.lock index cab1bbf..e064fa6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - cacheable (2.0.0) + cacheable (2.1.0) GEM remote: https://rubygems.org/ @@ -14,9 +14,7 @@ GEM reline (>= 0.6.0) coderay (1.1.3) diff-lcs (1.6.2) - ffi (1.17.3-arm64-darwin) - ffi (1.17.3-x86_64-darwin) - ffi (1.17.3-x86_64-linux-gnu) + ffi (1.17.3) formatador (1.2.3) reline guard (2.20.1) @@ -118,10 +116,7 @@ GEM unicode-emoji (4.2.0) PLATFORMS - arm64-darwin-23 - x86_64-darwin-20 - x86_64-darwin-21 - x86_64-linux + ruby DEPENDENCIES cacheable! diff --git a/README.md b/README.md index a44e9f0..f845d44 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,9 @@ end > a = GitHubApiAdapter.new > a.star_count Fetching data from GitHub - => 19 + => 58 > a.star_count - => 19 + => 58 # Notice that "Fetching data from GitHub" was not output the 2nd time the method was invoked. # 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 > a = GitHubApiAdapter.new > a.star_count Fetching data from GitHub - => 19 + => 58 > a.star_count_without_cache Fetching data from GitHub - => 19 + => 58 > a.star_count - => 19 + => 58 ``` #### 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 > a = GitHubApiAdapter.new > a.star_count Fetching data from GitHub - => 19 + => 58 > a.star_count - => 19 + => 58 > a.clear_star_count_cache => true > a.star_count Fetching data from GitHub - => 19 + => 58 ``` ## Additional Configuration @@ -135,7 +135,7 @@ Fetching data from GitHub #### Default -By default, Cacheable will construct a key in the format `[cache_key || class_name, method_name]` without using method arguments. +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. 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. @@ -155,12 +155,13 @@ require 'net/http' class GitHubApiAdapter include Cacheable - cacheable :star_count, key_format: ->(target, method_name, method_args) do - [target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/') + cacheable :star_count, key_format: ->(target, method_name, method_args, **kwargs) do + date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d')) + [target.class, method_name, method_args.first, date].join('/') end - def star_count(repo) - puts "Fetching data from GitHub for #{repo}" + def star_count(repo, date: Time.now.strftime('%Y-%m-%d')) + puts "Fetching data from GitHub for #{repo} (as of #{date})" url = "https://api.github.com/repos/splitwise/#{repo}" JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count'] @@ -170,33 +171,34 @@ end * `target` is the object the method is being called on (`#`) * `method_name` is the name of the method being cached (`:star_count`) -* `method_args` is an array of arguments being passed to the method (`[params]`) +* `method_args` is an array of positional arguments being passed to the method (`[params]`) +* `**kwargs` are the keyword arguments being passed to the method 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. -In addition, we're including the current date in the cache key so calling this method tomorrow will return an updated value. +**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. ```irb > a = GitHubApiAdapter.new > a.star_count('cacheable') -Fetching data from GitHub for cacheable - => 19 +Fetching data from GitHub for cacheable (as of 2026-02-26) + => 58 > a.star_count('cacheable') - => 19 + => 58 > a.star_count('tokenautocomplete') -Fetching data from GitHub for tokenautocomplete - => 1164 +Fetching data from GitHub for tokenautocomplete (as of 2026-02-26) + => 1309 > a.star_count('tokenautocomplete') - => 1164 + => 1309 # In this example the follow cache keys are generated: - # GitHubApiAdapter/star_count/cacheable/2018-09-21 - # GitHubApiAdapter/star_count/tokenautocomplete/2018-09-21 + # GitHubApiAdapter/star_count/cacheable/2026-02-26 + # GitHubApiAdapter/star_count/tokenautocomplete/2026-02-26 ``` ### Conditional Caching -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`. +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`. ```ruby # From examples/conditional_example.rb @@ -208,18 +210,19 @@ require 'net/http' class GitHubApiAdapter include Cacheable - cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do - [target.class, method_name, method_args.first].join('/') + cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args, **kwargs) do + date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d')) + [target.class, method_name, method_args.first, date].join('/') end - def star_count(repo) - puts "Fetching data from GitHub for #{repo}" + def star_count(repo, date: Time.now.strftime('%Y-%m-%d')) + puts "Fetching data from GitHub for #{repo} (as of #{date})" url = "https://api.github.com/repos/splitwise/#{repo}" JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count'] end - def growing_fast?(_method_name, method_args) + def growing_fast?(_method_name, method_args, **) method_args.first == 'cacheable' end end @@ -230,17 +233,17 @@ Cacheable is new so we don't want to cache the number of stars it has as we expe ```irb > a = GitHubApiAdapter.new > a.star_count('tokenautocomplete') -Fetching data from GitHub for tokenautocomplete - => 1164 +Fetching data from GitHub for tokenautocomplete (as of 2026-02-26) + => 1309 a.star_count('tokenautocomplete') - => 1164 + => 1309 > a.star_count('cacheable') -Fetching data from GitHub for cacheable - => 19 +Fetching data from GitHub for cacheable (as of 2026-02-26) + => 58 > a.star_count('cacheable') -Fetching data from GitHub for cacheable - => 19 +Fetching data from GitHub for cacheable (as of 2026-02-26) + => 58 ``` ### Cache Options @@ -251,6 +254,26 @@ If your cache backend supports options, you can pass them as the `cache_options: cacheable :with_options, cache_options: {expires_in: 3_600} ``` +### Per-Class Cache Adapter + +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: + +```ruby +class FrequentlyAccessedModel + include Cacheable + + self.cache_adapter = MyFasterCache.new + + cacheable :expensive_lookup + + def expensive_lookup + # ... + end +end +``` + +The class-level adapter takes precedence over the global adapter. Classes without their own adapter fall back to `Cacheable.cache_adapter` as usual. + ### Flexible Options You can use the same options with multiple cache methods or limit them only to specific methods: @@ -302,15 +325,15 @@ end ```irb > GitHubApiAdapter.star_count_for_cacheable Fetching data from GitHub for cacheable - => 19 + => 58 > GitHubApiAdapter.star_count_for_cacheable - => 19 + => 58 > GitHubApiAdapter.star_count_for_tokenautocomplete Fetching data from GitHub for tokenautocomplete - => 1164 + => 1309 > GitHubApiAdapter.star_count_for_tokenautocomplete - => 1164 + => 1309 ``` ### Other Notes / Frequently Asked Questions diff --git a/examples/class_method_example.rb b/examples/class_method_example.rb index 5d23e0a..b466b2f 100644 --- a/examples/class_method_example.rb +++ b/examples/class_method_example.rb @@ -29,12 +29,12 @@ def self.star_count_for_tokenautocomplete GitHubApiAdapter.star_count_for_cacheable # Fetching data from GitHub for cacheable -# => 19 +# => 58 GitHubApiAdapter.star_count_for_cacheable -# => 19 +# => 58 GitHubApiAdapter.star_count_for_tokenautocomplete # Fetching data from GitHub for tokenautocomplete -# => 1164 +# => 1309 GitHubApiAdapter.star_count_for_tokenautocomplete -# => 1164 +# => 1309 diff --git a/examples/conditional_example.rb b/examples/conditional_example.rb index f0d7abc..59b98da 100644 --- a/examples/conditional_example.rb +++ b/examples/conditional_example.rb @@ -5,32 +5,33 @@ class GitHubApiAdapter include Cacheable - cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do - [target.class, method_name, method_args.first].join('/') + cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args, **kwargs) do + date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d')) + [target.class, method_name, method_args.first, date].join('/') end - def star_count(repo) - puts "Fetching data from GitHub for #{repo}" + def star_count(repo, date: Time.now.strftime('%Y-%m-%d')) + puts "Fetching data from GitHub for #{repo} (as of #{date})" url = "https://api.github.com/repos/splitwise/#{repo}" JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count'] end - def growing_fast?(_method_name, method_args) + def growing_fast?(_method_name, method_args, **) method_args.first == 'cacheable' end end a = GitHubApiAdapter.new a.star_count('tokenautocomplete') -# Fetching data from GitHub for tokenautocomplete -# => 1164 +# Fetching data from GitHub for tokenautocomplete (as of 2026-02-26) +# => 1309 a.star_count('tokenautocomplete') -# => 1164 +# => 1309 a.star_count('cacheable') -# Fetching data from GitHub for cacheable -# => 19 +# Fetching data from GitHub for cacheable (as of 2026-02-26) +# => 58 a.star_count('cacheable') -# Fetching data from GitHub for cacheable -# => 19 +# Fetching data from GitHub for cacheable (as of 2026-02-26) +# => 58 diff --git a/examples/custom_key_example.rb b/examples/custom_key_example.rb index 3ef669c..950d889 100644 --- a/examples/custom_key_example.rb +++ b/examples/custom_key_example.rb @@ -5,12 +5,13 @@ class GitHubApiAdapter include Cacheable - cacheable :star_count, key_format: ->(target, method_name, method_args) do - [target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/') + cacheable :star_count, key_format: ->(target, method_name, method_args, **kwargs) do + date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d')) + [target.class, method_name, method_args.first, date].join('/') end - def star_count(repo) - puts "Fetching data from GitHub for #{repo}" + def star_count(repo, date: Time.now.strftime('%Y-%m-%d')) + puts "Fetching data from GitHub for #{repo} (as of #{date})" url = "https://api.github.com/repos/splitwise/#{repo}" JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count'] @@ -19,12 +20,12 @@ def star_count(repo) a = GitHubApiAdapter.new a.star_count('cacheable') -# Fetching data from GitHub for cacheable -# => 19 +# Fetching data from GitHub for cacheable (as of 2026-02-26) +# => 58 a.star_count('cacheable') -# => 19 +# => 58 a.star_count('tokenautocomplete') -# Fetching data from GitHub for tokenautocomplete -# => 1164 +# Fetching data from GitHub for tokenautocomplete (as of 2026-02-26) +# => 1309 a.star_count('tokenautocomplete') -# => 1164 +# => 1309 diff --git a/examples/simple_example.rb b/examples/simple_example.rb index 47606d6..b5fac8e 100644 --- a/examples/simple_example.rb +++ b/examples/simple_example.rb @@ -18,18 +18,18 @@ def star_count a = GitHubApiAdapter.new a.star_count # Fetching data from GitHub -# => 19 +# => 58 a.star_count -# => 19 +# => 58 a.star_count_without_cache # Fetching data from GitHub -# => 19 +# => 58 a.star_count -# => 19 +# => 58 a.clear_star_count_cache # => true a.star_count # Fetching data from GitHub -# => 19 +# => 58 diff --git a/lib/cacheable.rb b/lib/cacheable.rb index e648914..450b1ea 100644 --- a/lib/cacheable.rb +++ b/lib/cacheable.rb @@ -32,11 +32,14 @@ module Cacheable extend CacheAdapter def self.included(base) + base.extend(Cacheable::CacheAdapter) base.extend(Cacheable::MethodGenerator) interceptor_name = base.send(:method_interceptor_module_name) - remove_const(interceptor_name) if const_defined?(interceptor_name) - - base.prepend const_set(interceptor_name, Module.new) + interceptor = Module.new + interceptor.define_singleton_method(:to_s) { interceptor_name } + interceptor.define_singleton_method(:inspect) { interceptor_name } + base.instance_variable_set(:@_cacheable_interceptor, interceptor) + base.prepend interceptor end end diff --git a/lib/cacheable/cache_adapter.rb b/lib/cacheable/cache_adapter.rb index 1adf9c0..96eb518 100644 --- a/lib/cacheable/cache_adapter.rb +++ b/lib/cacheable/cache_adapter.rb @@ -7,11 +7,11 @@ module CacheAdapter def self.extended(base) base.instance_variable_set(:@_cache_adapter, nil) - base.cache_adapter = DEFAULT_ADAPTER + base.cache_adapter = DEFAULT_ADAPTER if base == Cacheable end def cache_adapter - @_cache_adapter + @_cache_adapter || (self == Cacheable ? nil : Cacheable.cache_adapter) end def cache_adapter=(name_or_adapter) diff --git a/lib/cacheable/cache_adapters/memory_adapter.rb b/lib/cacheable/cache_adapters/memory_adapter.rb index 60129b3..59b8104 100644 --- a/lib/cacheable/cache_adapters/memory_adapter.rb +++ b/lib/cacheable/cache_adapters/memory_adapter.rb @@ -1,42 +1,50 @@ +# frozen_string_literal: true + +require 'monitor' + module Cacheable module CacheAdapters class MemoryAdapter def initialize + @monitor = Monitor.new clear end def read(key) - cache[key] + @monitor.synchronize { @cache[key] } end def write(key, value) - cache[key] = value + @monitor.synchronize { @cache[key] = value } end def exist?(key) - cache.key?(key) + @monitor.synchronize { @cache.key?(key) } end + # NOTE: yield is intentionally called inside the lock to prevent thundering herd — only one thread + # computes a missing value while others wait. This is acceptable for a simple in-memory adapter; + # production use cases needing high concurrency should use a real cache backend via CacheAdapter. def fetch(key, _options = {}) - return read(key) if exist?(key) + @monitor.synchronize do + return @cache[key] if @cache.key?(key) - write(key, yield) + @cache[key] = yield + end end - def delete(key) # rubocop:disable Naming/PredicateMethod -- mimics the ActiveSupport::Cache::Store#delete interface and isn't a predicate - return false unless exist?(key) + def delete(key) + @monitor.synchronize do + return false unless @cache.key?(key) - cache.delete key - true + @cache.delete(key) + true + end end def clear - @cache = {} + @monitor.synchronize { @cache = {} } end - - private - - attr_reader :cache end end end diff --git a/lib/cacheable/method_generator.rb b/lib/cacheable/method_generator.rb index c2b47d8..23c200d 100644 --- a/lib/cacheable/method_generator.rb +++ b/lib/cacheable/method_generator.rb @@ -15,47 +15,56 @@ def method_interceptor_module_name "#{class_name}Cacher" end - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def create_cacheable_methods(original_method_name, opts = {}) method_names = create_method_names(original_method_name) key_format_proc = opts[:key_format] || default_key_format - const_get(method_interceptor_module_name).class_eval do - define_method(method_names[:key_format_method_name]) do |*args| - key_format_proc.call(self, original_method_name, args) + unless_proc = opts[:unless].is_a?(Symbol) ? opts[:unless].to_proc : opts[:unless] + + @_cacheable_interceptor.class_eval do + define_method(method_names[:key_format_method_name]) do |*args, **kwargs| + key_format_proc.call(self, original_method_name, args, **kwargs) end - define_method(method_names[:clear_cache_method_name]) do |*args| - Cacheable.cache_adapter.delete(__send__(method_names[:key_format_method_name], *args)) + define_method(method_names[:clear_cache_method_name]) do |*args, **kwargs| + adapter = (is_a?(Module) ? singleton_class : self.class).cache_adapter + adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs)) end - define_method(method_names[:without_cache_method_name]) do |*args| - original_method = method(original_method_name).super_method - original_method.call(*args) + define_method(method_names[:without_cache_method_name]) do |*args, **kwargs, &block| + method(original_method_name).super_method.call(*args, **kwargs, &block) end - define_method(method_names[:with_cache_method_name]) do |*args| - 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 - __send__(method_names[:without_cache_method_name], *args) + define_method(method_names[:with_cache_method_name]) do |*args, **kwargs, &block| + adapter = (is_a?(Module) ? singleton_class : self.class).cache_adapter + 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 + __send__(method_names[:without_cache_method_name], *args, **kwargs, &block) end end - define_method(original_method_name) do |*args| - unless_proc = opts[:unless].is_a?(Symbol) ? opts[:unless].to_proc : opts[:unless] - - if unless_proc&.call(self, original_method_name, args) - __send__(method_names[:without_cache_method_name], *args) + define_method(original_method_name) do |*args, **kwargs, &block| + if unless_proc&.call(self, original_method_name, args, **kwargs) + __send__(method_names[:without_cache_method_name], *args, **kwargs, &block) else - __send__(method_names[:with_cache_method_name], *args) + __send__(method_names[:with_cache_method_name], *args, **kwargs, &block) end end end end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def default_key_format - proc do |target, method_name, _method_args| - # By default, we omit the _method_args from the cache key because there is no acceptable default behavior + warned = false + + proc do |target, method_name, method_args, **kwargs| + if !warned && (!method_args.empty? || !kwargs.empty?) + warn "Cacheable WARNING: '#{method_name}' is using the default key format but was called with " \ + 'arguments. Arguments are NOT included in the cache key, so different arguments will return ' \ + 'the same cached value. Provide a :key_format proc to include arguments in the cache key.' + warned = true + end + class_name = (target.is_a?(Module) ? target.name : target.class.name) cache_key = target.respond_to?(:cache_key) ? target.cache_key : class_name [cache_key, method_name].compact diff --git a/lib/cacheable/version.rb b/lib/cacheable/version.rb index 68d6e8a..25d2219 100644 --- a/lib/cacheable/version.rb +++ b/lib/cacheable/version.rb @@ -3,7 +3,7 @@ module Cacheable module VERSION MAJOR = 2 - MINOR = 0 + MINOR = 1 TINY = 0 PRE = nil diff --git a/spec/cacheable/cacheable_spec.rb b/spec/cacheable/cacheable_spec.rb index 85a8cfe..dcec584 100644 --- a/spec/cacheable/cacheable_spec.rb +++ b/spec/cacheable/cacheable_spec.rb @@ -43,7 +43,7 @@ arg = 'an argument' expect(cacheable_object).to receive(cacheable_method_inner).with(arg) - cacheable_object.send(cacheable_method, arg) + expect { cacheable_object.send(cacheable_method, arg) }.to output.to_stderr end it 'creates a method that can skip the cache' do @@ -89,22 +89,17 @@ end it 'uses the class name to define an interceptor module' do - # This is done specifically this way to be compatible w/ RSpec best practices - # Once Cacheable is included in a class, it uses the name of the class to define the - # interceptor module. However, it is considered bad practice to create constants in RSpec - # so they're typically made with `stub_const`. We need to include Cacheable after the - # anonymous class has been created and assigned to the stubbed constant for this order to work. stub_const('RealClassName', Class.new) - class_name = RealClassName.include(described_class) + RealClassName.include(described_class) - expect(class_name.ancestors.map(&:to_s)).to include("Cacheable::#{class_name}Cacher") + expect(RealClassName.ancestors.map(&:to_s)).to include('RealClassNameCacher') end it 'uses the class address to define an interceptor module for anonymous classes' do custom_class = Class.new { include Cacheable } class_name = custom_class.to_s.tr('#:<>', '') - expect(custom_class.ancestors.map(&:to_s)).to include("Cacheable::#{class_name}Cacher") + expect(custom_class.ancestors.map(&:to_s)).to include("#{class_name}Cacher") end context 'when the method name has special characters' do @@ -126,11 +121,78 @@ stub_const('Outer::Inner', Class.new) Outer::Inner.include(described_class) - expect(Outer::Inner.ancestors.map(&:to_s)).to include('Cacheable::OuterInnerCacher') + expect(Outer::Inner.ancestors.map(&:to_s)).to include('OuterInnerCacher') end end end + describe 'keyword arguments' do + it 'forwards keyword arguments to the original method' do + cacheable_class.class_eval do + define_method(:method_with_kwargs) do |name:, greeting: 'Hello'| + "#{greeting}, #{name}" + end + + cacheable :method_with_kwargs, key_format: proc { |_, _, args, **kwargs| [args, kwargs] } + end + + expect(cacheable_object.method_with_kwargs(name: 'World')).to eq('Hello, World') + expect(cacheable_object.method_with_kwargs(name: 'World', greeting: 'Hi')).to eq('Hi, World') + end + + it 'forwards keyword arguments when skipping cache' do + cacheable_class.class_eval do + define_method(:kwargs_no_cache) do |val:| + val + end + + cacheable :kwargs_no_cache, key_format: proc { |_, _, args, **kwargs| [args, kwargs] } + end + + expect(cacheable_object.kwargs_no_cache_without_cache(val: 42)).to eq(42) + end + + it 'forwards mixed positional and keyword arguments' do + cacheable_class.class_eval do + define_method(:mixed_args) do |pos, key:| + "#{pos}-#{key}" + end + + cacheable :mixed_args, key_format: proc { |_, _, args, **kwargs| [args, kwargs] } + end + + expect(cacheable_object.mixed_args('a', key: 'b')).to eq('a-b') + end + end + + describe 'block forwarding' do + it 'forwards blocks to the original method on cache miss' do + cacheable_class.class_eval do + define_method(:method_with_block) do |&block| + block.call('from cache miss') + end + + cacheable :method_with_block + end + + result = cacheable_object.method_with_block { |msg| "got: #{msg}" } + expect(result).to eq('got: from cache miss') + end + + it 'forwards blocks when skipping cache' do + cacheable_class.class_eval do + define_method(:block_no_cache) do |&block| + block.call('direct') + end + + cacheable :block_no_cache + end + + result = cacheable_object.block_no_cache_without_cache { |msg| "got: #{msg}" } + expect(result).to eq('got: direct') + end + end + describe 'interceptor module' do it 'has the public generated methods' do expect(cacheable_class.ancestors.first.instance_methods(false)).to include(cacheable_method, :"#{cacheable_method}_without_cache", :"#{cacheable_method}_with_cache", :"#{cacheable_method}_key_format") @@ -156,9 +218,28 @@ .to change { described_class.cache_adapter.exist?(key) }.from(false).to(true) end - it 'does not use the arguments to the method to determine the cache key' do - args = [1] - expect(cacheable_object.cacheable_method_key_format(*args)).to eq([cacheable_method]) + it 'does not use positional arguments in the cache key and warns' do + cache_key = nil + expect { cache_key = cacheable_object.cacheable_method_key_format(1) } + .to output(/default key format.*arguments are NOT included/i).to_stderr + expect(cache_key).to eq([cacheable_method]) + end + + it 'does not use keyword arguments in the cache key and warns' do + cache_key = nil + expect { cache_key = cacheable_object.cacheable_method_key_format(foo: 1) } + .to output(/default key format.*arguments are NOT included/i).to_stderr + expect(cache_key).to eq([cacheable_method]) + end + + it 'only warns once per method' do + expect { cacheable_object.cacheable_method_key_format(1) } + .to output(/default key format/i).to_stderr + expect { cacheable_object.cacheable_method_key_format(2) }.not_to output.to_stderr + end + + it 'does not warn when called without arguments' do + expect { cacheable_object.cacheable_method_key_format }.not_to output.to_stderr end it 'uses different keys for different cached values' do @@ -177,8 +258,10 @@ expect(cacheable_object).to receive(inner_method).with(arg1).once.and_call_original expect(cacheable_object).to receive(inner_method).with(arg2).once.and_call_original - 2.times { expect(cacheable_object.send(cacheable_method, arg1)).to include(arg1) } - 2.times { expect(cacheable_object.send(another_cacheable_method, arg2)).to include(arg2) } + expect do + 2.times { expect(cacheable_object.send(cacheable_method, arg1)).to include(arg1) } + 2.times { expect(cacheable_object.send(another_cacheable_method, arg2)).to include(arg2) } + end.to output.to_stderr end it 'uses the value of `cache_key` if the method is defined instead of the class' do @@ -278,8 +361,10 @@ cacheable(*local_variable_so_class_eval_works) end - expect(described_class.cache_adapter).to receive(:write).twice.and_call_original 2.times { cache_methods.each { |method| cacheable_object.send(method) } } + cache_methods.each do |method| + expect(described_class.cache_adapter.exist?([method])).to be true + end end it 'uses the same options for cacheable methods declared on a single line' do @@ -289,8 +374,10 @@ cacheable(*local_variable_so_class_eval_works, unless: proc { true }) end - expect(described_class.cache_adapter).not_to receive(:write) 2.times { cache_methods.each { |method| cacheable_object.send(method) } } + cache_methods.each do |method| + expect(described_class.cache_adapter.exist?([method])).to be false + end end it 'can take strings' do @@ -300,8 +387,10 @@ cacheable(*cache_methods_as_strings) end - expect(described_class.cache_adapter).to receive(:write).twice.and_call_original 2.times { cache_methods.each { |method| cacheable_object.send(method) } } + cache_methods_as_strings.each do |method| + expect(described_class.cache_adapter.exist?([method])).to be true + end end it 'can take strings before the method is defined' do @@ -434,6 +523,20 @@ def cache_control_method expect(cacheable_object).to receive(inner_method).twice.and_call_original 2.times { cacheable_object.send(cache_depends_on_args, the_method_arg) } end + + it 'passes kwargs to the `unless` proc' do + cache_depends_on_kwargs = :cache_depends_on_kwargs + inner_method = cacheable_method_inner + cacheable_class.class_eval do + define_method(cache_depends_on_kwargs) do |force: false| # rubocop:disable Lint/UnusedBlockArgument + send inner_method + end + + cacheable cache_depends_on_kwargs, unless: proc { |_, _, _, force: false| force } + end + expect(cacheable_object).to receive(inner_method).twice.and_call_original + 2.times { cacheable_object.send(cache_depends_on_kwargs, force: true) } + end end describe 'on class methods' do @@ -450,6 +553,37 @@ def cache_control_method end end + describe 'per-class cache adapter' do + it 'falls back to the global adapter by default' do + expect(cacheable_class.cache_adapter).to eq(described_class.cache_adapter) + end + + it 'allows setting a class-specific adapter' do + class_adapter = Cacheable::CacheAdapters::MemoryAdapter.new + cacheable_class.cache_adapter = class_adapter + + expect(cacheable_class.cache_adapter).to eq(class_adapter) + expect(cacheable_class.cache_adapter).not_to eq(described_class.cache_adapter) + end + + it 'uses the class adapter for caching when set' do + class_adapter = Cacheable::CacheAdapters::MemoryAdapter.new + cacheable_class.cache_adapter = class_adapter + + cacheable_object.send(cacheable_method) + expect(class_adapter.exist?([cacheable_method])).to be true + expect(described_class.cache_adapter.exist?([cacheable_method])).to be false + end + + it 'does not affect other classes' do + other_class = Class.new.tap { |klass| klass.class_exec(&class_definition) } + class_adapter = Cacheable::CacheAdapters::MemoryAdapter.new + cacheable_class.cache_adapter = class_adapter + + expect(other_class.cache_adapter).to eq(described_class.cache_adapter) + end + end + it 'passes `cache_options` to the cache client' do cache_options = {expires_in: 3_600} cache_method_with_cache_options = :cache_method_with_cache_options diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9f5b8d8..5f87bc7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,4 @@ -require 'bundler' -Bundler.require(:test) - +require 'bundler/setup' require 'cacheable' # This file was generated by the `rspec --init` command. Conventionally, all