Skip to content

Commit 0c65d19

Browse files
authored
Fix memoization with Hash-like objects as parameters (#25)
After adding `**kwargs` due to warnings in Ruby 2.7, Hash-like parameters (with `#to_hash` method) convert to Hash while passed in dynamically created by Memery method. For example, instances of `Sequel::Model`. Found Ruby bug: https://bugs.ruby-lang.org/issues/14909 So, changes will be in Ruby 2.8. And for now we can: 1. Use `**(;{})` as the additional parameter at method call, what is ugly. 2. Define memoized methods with `eval` and exactly expected arguments, what is dangerous (eval is evil). 3. Suppress warnings about keyword arguments until here are no failing tests. First, I've chose the 3th. But then Jeremy Evans suggest to use `ruby2_keywords`. So, here we're! Additional line of code offensed RuboCop (method length), so I was forced to refactor method definition.
1 parent ad6b252 commit 0c65d19

File tree

4 files changed

+67
-32
lines changed

4 files changed

+67
-32
lines changed

Gemfile.lock

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ PATH
22
remote: .
33
specs:
44
memery (1.3.0)
5+
ruby2_keywords (~> 0.0.2)
56

67
GEM
78
remote: https://rubygems.org/
@@ -65,6 +66,7 @@ GEM
6566
rubocop-rspec (1.37.1)
6667
rubocop (>= 0.68.1)
6768
ruby-progressbar (1.10.1)
69+
ruby2_keywords (0.0.2)
6870
simplecov (0.16.1)
6971
docile (~> 1.1)
7072
json (>= 1.8, < 3)

lib/memery.rb

+45-32
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
# frozen_string_literal: true
22

3+
require "ruby2_keywords"
4+
35
require "memery/version"
46

57
module Memery
68
class << self
7-
def method_visibility(klass, method_name)
8-
case
9-
when klass.private_method_defined?(method_name)
10-
:private
11-
when klass.protected_method_defined?(method_name)
12-
:protected
13-
when klass.public_method_defined?(method_name)
14-
:public
15-
end
16-
end
17-
189
def monotonic_clock
1910
Process.clock_gettime(Process::CLOCK_MONOTONIC)
2011
end
@@ -48,39 +39,61 @@ def memoized?(method_name)
4839

4940
def prepend_memery_module!
5041
return if defined?(@_memery_module)
51-
@_memery_module = Module.new
42+
@_memery_module = Module.new do
43+
extend MemoizationModule
44+
end
5245
prepend @_memery_module
5346
end
5447

55-
def define_memoized_method!(method_name, condition: nil, ttl: nil)
56-
visibility = Memery.method_visibility(self, method_name)
57-
raise ArgumentError, "Method #{method_name} is not defined on #{self}" unless visibility
48+
def define_memoized_method!(*args, **kwargs)
49+
@_memery_module.public_send __method__, self, *args, **kwargs
50+
end
5851

59-
method_key = "#{method_name}_#{@_memery_module.object_id}"
52+
module MemoizationModule
53+
def define_memoized_method!(klass, method_name, condition: nil, ttl: nil)
54+
method_key = "#{method_name}_#{object_id}"
6055

61-
# Change to regular call of `define_method` after Ruby 2.4 drop
62-
@_memery_module.send :define_method, method_name, (lambda do |*args, **kwargs, &block|
63-
if block || (condition && !instance_exec(&condition))
64-
return kwargs.any? ? super(*args, **kwargs, &block) : super(*args, &block)
65-
end
56+
original_visibility = method_visibility(klass, method_name)
6657

67-
args_key = [args, kwargs]
58+
define_method method_name do |*args, &block|
59+
if block || (condition && !instance_exec(&condition))
60+
return super(*args, &block)
61+
end
6862

69-
store = (@_memery_memoized_values ||= {})[method_key] ||= {}
63+
store = (@_memery_memoized_values ||= {})[method_key] ||= {}
7064

71-
if store.key?(args_key) &&
72-
(ttl.nil? || Memery.monotonic_clock <= store[args_key][:time] + ttl)
73-
return store[args_key][:result]
65+
if store.key?(args) &&
66+
(ttl.nil? || Memery.monotonic_clock <= store[args][:time] + ttl)
67+
return store[args][:result]
68+
end
69+
70+
result = super(*args)
71+
@_memery_memoized_values[method_key][args] =
72+
{ result: result, time: Memery.monotonic_clock }
73+
result
7474
end
7575

76-
result = kwargs.any? ? super(*args, **kwargs) : super(*args)
77-
@_memery_memoized_values[method_key][args_key] =
78-
{ result: result, time: Memery.monotonic_clock }
79-
result
80-
end)
76+
ruby2_keywords method_name
77+
78+
send original_visibility, method_name
79+
end
8180

82-
@_memery_module.send(visibility, method_name)
81+
private
82+
83+
def method_visibility(klass, method_name)
84+
if klass.private_method_defined?(method_name)
85+
:private
86+
elsif klass.protected_method_defined?(method_name)
87+
:protected
88+
elsif klass.public_method_defined?(method_name)
89+
:public
90+
else
91+
raise ArgumentError, "Method #{method_name} is not defined on #{klass}"
92+
end
93+
end
8394
end
95+
96+
private_constant :MemoizationModule
8497
end
8598

8699
module InstanceMethods

memery.gemspec

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Gem::Specification.new do |spec|
2020
end
2121
spec.require_paths = ["lib"]
2222

23+
spec.add_runtime_dependency "ruby2_keywords", "~> 0.0.2"
24+
2325
spec.add_development_dependency "benchmark-ips"
2426
spec.add_development_dependency "benchmark-memory"
2527
spec.add_development_dependency "bundler"

spec/memery_spec.rb

+18
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,24 @@ def self.macro(name)
164164
expect(values).to eq([[1, 1], [1, 1], [1, 2]])
165165
expect(CALLS).to eq([[1, 1], [1, 2]])
166166
end
167+
168+
context "receiving Hash-like object" do
169+
let(:object_class) do
170+
Struct.new(:first_name, :last_name) do
171+
# For example, Sequel models have such implicit coercion,
172+
# which conflicts with `**kwargs`.
173+
alias_method :to_hash, :to_h
174+
end
175+
end
176+
177+
let(:object) { object_class.new("John", "Wick") }
178+
179+
specify do
180+
values = [ a.m_args(1, object), a.m_args(1, object), a.m_args(1, 2) ]
181+
expect(values).to eq([[1, object], [1, object], [1, 2]])
182+
expect(CALLS).to eq([[1, object], [1, 2]])
183+
end
184+
end
167185
end
168186

169187
context "method with keyword args" do

0 commit comments

Comments
 (0)