369 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Ruby
		
	
	
	
			
		
		
	
	
			369 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Ruby
		
	
	
	
| # frozen_string_literal: true
 | ||
| 
 | ||
| require 'spec_helper'
 | ||
| require 'active_support/testing/time_helpers'
 | ||
| 
 | ||
| RSpec.describe Gitlab::Utils::StrongMemoize, feature_category: :shared do
 | ||
|   include ActiveSupport::Testing::TimeHelpers
 | ||
| 
 | ||
|   let(:klass) do
 | ||
|     strong_memoize_class = described_class
 | ||
| 
 | ||
|     Struct.new(:value) do
 | ||
|       include strong_memoize_class
 | ||
| 
 | ||
|       def self.method_added_list
 | ||
|         @method_added_list ||= []
 | ||
|       end
 | ||
| 
 | ||
|       def self.method_added(name)
 | ||
|         method_added_list << name
 | ||
|       end
 | ||
| 
 | ||
|       def method_name
 | ||
|         strong_memoize(:method_name) do # rubocop:disable Gitlab/StrongMemoizeAttr
 | ||
|           trace << value
 | ||
|           value
 | ||
|         end
 | ||
|       end
 | ||
| 
 | ||
|       def method_name_with_expiration
 | ||
|         strong_memoize_with_expiration(:method_name_with_expiration, 1) do
 | ||
|           trace << value
 | ||
|           value
 | ||
|         end
 | ||
|       end
 | ||
| 
 | ||
|       def method_name_attr
 | ||
|         trace << value
 | ||
|         value
 | ||
|       end
 | ||
|       strong_memoize_attr :method_name_attr
 | ||
| 
 | ||
|       def enabled?
 | ||
|         trace << value
 | ||
|         value
 | ||
|       end
 | ||
|       strong_memoize_attr :enabled?
 | ||
| 
 | ||
|       def method_name_with_args(*args)
 | ||
|         strong_memoize_with(:method_name_with_args, args) do
 | ||
|           trace << [value, args]
 | ||
|           value
 | ||
|         end
 | ||
|       end
 | ||
| 
 | ||
|       def trace
 | ||
|         @trace ||= []
 | ||
|       end
 | ||
| 
 | ||
|       protected
 | ||
| 
 | ||
|       def private_method; end
 | ||
|       private :private_method
 | ||
|       strong_memoize_attr :private_method
 | ||
| 
 | ||
|       public
 | ||
| 
 | ||
|       def protected_method; end
 | ||
|       protected :protected_method
 | ||
|       strong_memoize_attr :protected_method
 | ||
| 
 | ||
|       private
 | ||
| 
 | ||
|       def public_method; end
 | ||
|       public :public_method
 | ||
|       strong_memoize_attr :public_method
 | ||
|     end
 | ||
|   end
 | ||
| 
 | ||
|   subject(:object) { klass.new(value) }
 | ||
| 
 | ||
|   shared_examples 'caching the value' do
 | ||
|     let(:member_name) { described_class.normalize_key(method_name) }
 | ||
| 
 | ||
|     it 'only calls the block once' do
 | ||
|       value0 = object.public_send(method_name)
 | ||
|       value1 = object.public_send(method_name)
 | ||
| 
 | ||
|       expect(value0).to eq(value)
 | ||
|       expect(value1).to eq(value)
 | ||
|       expect(object.trace).to contain_exactly(value)
 | ||
|     end
 | ||
| 
 | ||
|     it 'returns and defines the instance variable for the exact value' do
 | ||
|       returned_value = object.public_send(method_name)
 | ||
|       memoized_value = object.instance_variable_get(:"@#{member_name}")
 | ||
| 
 | ||
|       expect(returned_value).to eql(value)
 | ||
|       expect(memoized_value).to eql(value)
 | ||
|     end
 | ||
|   end
 | ||
| 
 | ||
|   describe '#strong_memoize' do
 | ||
|     [nil, false, true, 'value', 0, [0]].each do |value|
 | ||
|       context "with value #{value}" do
 | ||
|         let(:value) { value }
 | ||
|         let(:method_name) { :method_name }
 | ||
| 
 | ||
|         it_behaves_like 'caching the value'
 | ||
| 
 | ||
|         it 'raises exception for invalid type as key' do
 | ||
|           expect { object.strong_memoize(10) { 20 } }.to raise_error(/Invalid type of '10'/)
 | ||
|         end
 | ||
| 
 | ||
|         it 'raises exception for invalid characters in key' do
 | ||
|           expect { object.strong_memoize(:enabled?) { 20 } }
 | ||
|             .to raise_error(/is not allowed as an instance variable name/)
 | ||
|         end
 | ||
|       end
 | ||
|     end
 | ||
| 
 | ||
|     context "with memory allocation", type: :benchmark do
 | ||
|       let(:value) { 'aaa' }
 | ||
| 
 | ||
|       before do
 | ||
|         object.method_name # warmup
 | ||
|       end
 | ||
| 
 | ||
|       [:method_name, "method_name"].each do |argument|
 | ||
|         context "when argument is a #{argument.class}" do
 | ||
|           it 'does allocate exactly one string when fetching value' do
 | ||
|             expect do
 | ||
|               object.strong_memoize(argument) { 10 }
 | ||
|             end.to perform_allocation(1)
 | ||
|           end
 | ||
| 
 | ||
|           it 'does allocate exactly one string when storing value' do
 | ||
|             object.clear_memoization(:method_name) # clear to force set
 | ||
| 
 | ||
|             expect do
 | ||
|               object.strong_memoize(argument) { 10 }
 | ||
|             end.to perform_allocation(1)
 | ||
|           end
 | ||
|         end
 | ||
|       end
 | ||
|     end
 | ||
|   end
 | ||
| 
 | ||
|   describe '#strong_memoize_with_expiration' do
 | ||
|     [nil, false, true, 'value', 0, [0]].each do |value|
 | ||
|       context "with value #{value}" do
 | ||
|         let(:value) { value }
 | ||
|         let(:method_name) { :method_name_with_expiration }
 | ||
| 
 | ||
|         it_behaves_like 'caching the value'
 | ||
| 
 | ||
|         it 'raises exception for invalid type as key' do
 | ||
|           expect { object.strong_memoize_with_expiration(10, 1) { 20 } }.to raise_error(/Invalid type of '10'/)
 | ||
|         end
 | ||
| 
 | ||
|         it 'raises exception for invalid characters in key' do
 | ||
|           expect { object.strong_memoize_with_expiration(:enabled?, 1) { 20 } }
 | ||
|             .to raise_error(/is not allowed as an instance variable name/)
 | ||
|         end
 | ||
|       end
 | ||
|     end
 | ||
| 
 | ||
|     context 'with value memoization test' do
 | ||
|       let(:value) { 'value' }
 | ||
| 
 | ||
|       it 'caches the value for specified number of seconds' do
 | ||
|         object.method_name_with_expiration
 | ||
|         object.method_name_with_expiration
 | ||
| 
 | ||
|         expect(object.trace.count).to eq(1)
 | ||
| 
 | ||
|         travel_to(Time.current + 2.seconds) do
 | ||
|           object.method_name_with_expiration
 | ||
| 
 | ||
|           expect(object.trace.count).to eq(2)
 | ||
|         end
 | ||
|       end
 | ||
|     end
 | ||
|   end
 | ||
| 
 | ||
|   describe '#strong_memoize_with' do
 | ||
|     [nil, false, true, 'value', 0, [0]].each do |value|
 | ||
|       context "with value #{value}" do
 | ||
|         let(:value) { value }
 | ||
| 
 | ||
|         it 'only calls the block once' do
 | ||
|           value0 = object.method_name_with_args(1)
 | ||
|           value1 = object.method_name_with_args(1)
 | ||
|           value2 = object.method_name_with_args([2, 3])
 | ||
|           value3 = object.method_name_with_args([2, 3])
 | ||
| 
 | ||
|           expect(value0).to eq(value)
 | ||
|           expect(value1).to eq(value)
 | ||
|           expect(value2).to eq(value)
 | ||
|           expect(value3).to eq(value)
 | ||
| 
 | ||
|           expect(object.trace).to contain_exactly([value, [1]], [value, [[2, 3]]])
 | ||
|         end
 | ||
| 
 | ||
|         it 'returns and defines the instance variable for the exact value' do
 | ||
|           returned_value = object.method_name_with_args(1, 2, 3)
 | ||
|           memoized_value = object.instance_variable_get(:@method_name_with_args)
 | ||
| 
 | ||
|           expect(returned_value).to eql(value)
 | ||
|           expect(memoized_value).to eql({ [[1, 2, 3]] => value })
 | ||
|         end
 | ||
|       end
 | ||
|     end
 | ||
|   end
 | ||
| 
 | ||
|   describe '#strong_memoized?' do
 | ||
|     shared_examples 'memoization check' do |method_name|
 | ||
|       context "when method is :#{method_name}" do
 | ||
|         let(:value) { :anything }
 | ||
| 
 | ||
|         subject { object.strong_memoized?(method_name) }
 | ||
| 
 | ||
|         it 'returns false if the value is uncached' do
 | ||
|           expect(subject).to be(false)
 | ||
|         end
 | ||
| 
 | ||
|         it 'returns true if the value is cached' do
 | ||
|           object.public_send(method_name)
 | ||
| 
 | ||
|           expect(subject).to be(true)
 | ||
|         end
 | ||
|       end
 | ||
|     end
 | ||
| 
 | ||
|     it_behaves_like 'memoization check', :method_name
 | ||
|     it_behaves_like 'memoization check', :enabled?
 | ||
|   end
 | ||
| 
 | ||
|   describe '#clear_memoization' do
 | ||
|     shared_examples 'clearing memoization' do |method_name|
 | ||
|       let(:member_name) { described_class.normalize_key(method_name) }
 | ||
|       let(:value) { 'mepmep' }
 | ||
| 
 | ||
|       it 'removes the instance variable' do
 | ||
|         object.public_send(method_name)
 | ||
| 
 | ||
|         object.clear_memoization(method_name)
 | ||
| 
 | ||
|         expect(object.instance_variable_defined?(:"@#{member_name}")).to be(false)
 | ||
|       end
 | ||
|     end
 | ||
| 
 | ||
|     it_behaves_like 'clearing memoization', :method_name
 | ||
|     it_behaves_like 'clearing memoization', :enabled?
 | ||
|   end
 | ||
| 
 | ||
|   describe '.strong_memoize_attr' do
 | ||
|     [nil, false, true, 'value', 0, [0]].each do |value|
 | ||
|       context "with value '#{value}'" do
 | ||
|         let(:value) { value }
 | ||
| 
 | ||
|         context 'with memoized after method definition' do
 | ||
|           let(:method_name) { :method_name_attr }
 | ||
| 
 | ||
|           it_behaves_like 'caching the value'
 | ||
| 
 | ||
|           it 'calls the existing .method_added' do
 | ||
|             expect(klass.method_added_list).to include(:method_name_attr)
 | ||
|           end
 | ||
| 
 | ||
|           it 'retains method arity' do
 | ||
|             expect(klass.instance_method(method_name).arity).to eq(0)
 | ||
|           end
 | ||
|         end
 | ||
|       end
 | ||
|     end
 | ||
| 
 | ||
|     describe 'method visibility' do
 | ||
|       it 'sets private visibility' do
 | ||
|         expect(klass.private_instance_methods).to include(:private_method)
 | ||
|         expect(klass.protected_instance_methods).not_to include(:private_method)
 | ||
|         expect(klass.public_instance_methods).not_to include(:private_method)
 | ||
|       end
 | ||
| 
 | ||
|       it 'sets protected visibility' do
 | ||
|         expect(klass.private_instance_methods).not_to include(:protected_method)
 | ||
|         expect(klass.protected_instance_methods).to include(:protected_method)
 | ||
|         expect(klass.public_instance_methods).not_to include(:protected_method)
 | ||
|       end
 | ||
| 
 | ||
|       it 'sets public visibility' do
 | ||
|         expect(klass.private_instance_methods).not_to include(:public_method)
 | ||
|         expect(klass.protected_instance_methods).not_to include(:public_method)
 | ||
|         expect(klass.public_instance_methods).to include(:public_method)
 | ||
|       end
 | ||
|     end
 | ||
| 
 | ||
|     context "when method doesn't exist" do
 | ||
|       let(:klass) do
 | ||
|         strong_memoize_class = described_class
 | ||
| 
 | ||
|         Struct.new(:value) do
 | ||
|           include strong_memoize_class
 | ||
|         end
 | ||
|       end
 | ||
| 
 | ||
|       subject { klass.strong_memoize_attr(:nonexistent_method) }
 | ||
| 
 | ||
|       it 'fails when strong-memoizing a nonexistent method' do
 | ||
|         expect { subject }.to raise_error(NameError, /undefined method `nonexistent_method' for class/)
 | ||
|       end
 | ||
|     end
 | ||
| 
 | ||
|     context 'when memoized method has parameters' do
 | ||
|       it 'raises an error' do
 | ||
|         expected_message = /Using `strong_memoize_attr` on methods with parameters is not supported/
 | ||
| 
 | ||
|         expect do
 | ||
|           strong_memoize_class = described_class
 | ||
| 
 | ||
|           Class.new do
 | ||
|             include strong_memoize_class
 | ||
| 
 | ||
|             def method_with_parameters(params); end
 | ||
|             strong_memoize_attr :method_with_parameters
 | ||
|           end
 | ||
|         end.to raise_error(RuntimeError, expected_message)
 | ||
|       end
 | ||
|     end
 | ||
|   end
 | ||
| 
 | ||
|   describe '.normalize_key' do
 | ||
|     using RSpec::Parameterized::TableSyntax
 | ||
| 
 | ||
|     subject { described_class.normalize_key(input) }
 | ||
| 
 | ||
|     where(:input, :output, :valid) do
 | ||
|       :key    | :key    | true
 | ||
|       "key"   | "key"   | true
 | ||
|       :key?   | "key?" | true
 | ||
|       "key?"  | "key?" | true
 | ||
|       :key!   | "key!" | true
 | ||
|       "key!"  | "key!" | true
 | ||
|       # invalid cases caught elsewhere
 | ||
|       :"ke?y" | :"ke?y" | false
 | ||
|       "ke?y"  | "ke?y"  | false
 | ||
|       :"ke!y" | :"ke!y" | false
 | ||
|       "ke!y"  | "ke!y"  | false
 | ||
|     end
 | ||
| 
 | ||
|     with_them do
 | ||
|       let(:ivar) { "@#{output}" }
 | ||
| 
 | ||
|       it { is_expected.to eq(output) }
 | ||
| 
 | ||
|       if params[:valid] # rubocop:disable RSpec/AvoidConditionalStatements
 | ||
|         it 'is a valid ivar name' do
 | ||
|           expect { instance_variable_defined?(ivar) }.not_to raise_error
 | ||
|         end
 | ||
|       else
 | ||
|         it 'raises a NameError error' do
 | ||
|           expect { instance_variable_defined?(ivar) }
 | ||
|             .to raise_error(NameError, /not allowed as an instance/)
 | ||
|         end
 | ||
|       end
 | ||
|     end
 | ||
|   end
 | ||
| end
 |