diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6c8a6a7..1c88732 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Publish to RubyGems run: | diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index 2392292..b90a550 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -7,20 +7,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Run tests run: | - docker-compose up -d + docker compose up -d docker exec gem_test_runner bash -c "cd gem_src && bundle install && bundle exec rspec" - - - name: Code Coverage - uses: paambaati/codeclimate-action@v5.0.0 - env: - CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} - with: - debug: true - coverageLocations: | - ${{github.workspace}}/coverage/coverage.json:simplecov - prefix: /gem_src - verifyDownload: true diff --git a/benchmark/indifferent_bench.rb b/benchmark/indifferent_bench.rb new file mode 100644 index 0000000..5bfc39d --- /dev/null +++ b/benchmark/indifferent_bench.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'bundler/setup' +require 'benchmark/ips' +require 'hash_kit' + +helper = HashKit::Helper.new + +# --------------------------------------------------------------------------- +# Hash builders – every benchmark iteration gets a fresh hash so that +# default_proc assignment is always exercised (no short-circuit). +# --------------------------------------------------------------------------- + +def build_small_flat_hash + { 'name' => 'Alice', 'age' => 30, 'active' => true } +end + +def build_large_flat_hash(n = 100) + (0...n).each_with_object({}) { |i, h| h["key_#{i}"] = "value_#{i}" } +end + +def build_nested_hash(depth = 5) + hash = { 'leaf' => 'value' } + depth.times { |i| hash = { "level_#{i}" => hash, "sibling_#{i}" => 'data' } } + hash +end + +def build_hash_with_arrays + { + 'users' => [ + { 'name' => 'Alice', 'roles' => [{ 'id' => 1, 'name' => 'admin' }] }, + { 'name' => 'Bob', 'roles' => [{ 'id' => 2, 'name' => 'editor' }] }, + { 'name' => 'Carol', 'roles' => [{ 'id' => 3, 'name' => 'viewer' }] } + ], + 'meta' => { 'page' => 1, 'total' => 3 } + } +end + +def build_large_nested_hash(width = 20, depth = 4) + return (0...width).each_with_object({}) { |i, h| h["key_#{i}"] = "val_#{i}" } if depth == 0 + + (0...width).each_with_object({}) do |i, h| + h["node_#{i}"] = build_large_nested_hash(width, depth - 1) + end +end + +# --------------------------------------------------------------------------- +# Benchmark suite +# --------------------------------------------------------------------------- + +puts 'HashKit::Helper#indifferent! benchmark' +puts "Ruby #{RUBY_VERSION} / benchmark-ips #{Benchmark::IPS::VERSION}" +puts '=' * 60 + +Benchmark.ips do |x| + x.config(warmup: 2, time: 5) + + # --- Scenario 1: small flat hash (3 keys) --- + x.report('small flat hash (3 keys)') do + helper.indifferent!(build_small_flat_hash) + end + + # --- Scenario 2: large flat hash (100 keys) --- + x.report('large flat hash (100 keys)') do + helper.indifferent!(build_large_flat_hash(100)) + end + + # --- Scenario 3: deeply nested hash (5 levels) --- + x.report('nested hash (depth=5)') do + helper.indifferent!(build_nested_hash(5)) + end + + # --- Scenario 4: hash containing arrays of hashes --- + x.report('hash with arrays') do + helper.indifferent!(build_hash_with_arrays) + end + + # --- Scenario 5: large nested hash (wide + deep) --- + x.report('large nested (10x3)') do + helper.indifferent!(build_large_nested_hash(10, 3)) + end + + # --- Scenario 6: many sequential calls on small hashes --- + x.report('50x sequential small hashes') do + 50.times { helper.indifferent!(build_small_flat_hash) } + end + + # --- Scenario 7: many sequential calls on medium hashes --- + x.report('50x sequential nested hashes') do + 50.times { helper.indifferent!(build_nested_hash(3)) } + end + + x.compare! +end diff --git a/docker-compose.yml b/docker-compose.yml index 1816492..38d6afe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: gem_test_runner: - image: ruby:2.7.8-bullseye + image: ruby:4 container_name: gem_test_runner command: bash -c "while true; do echo 'Container is running...'; sleep 2; done" volumes: - - ./:/gem_src + - ./:/gem_src diff --git a/hash_kit.gemspec b/hash_kit.gemspec index bc7b4cc..3809aef 100644 --- a/hash_kit.gemspec +++ b/hash_kit.gemspec @@ -1,6 +1,6 @@ # frozen_string_literal: true -lib = File.expand_path('../lib', __FILE__) +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'hash_kit/version' @@ -20,9 +20,12 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] + spec.required_ruby_version = '>= 3.0.0' + + spec.add_development_dependency 'benchmark-ips', '~> 2.0' spec.add_development_dependency 'bundler' spec.add_development_dependency 'pry' spec.add_development_dependency 'rake' spec.add_development_dependency 'rspec' - spec.add_development_dependency 'simplecov', '~> 0.21' + spec.add_development_dependency 'simplecov' end diff --git a/lib/hash_kit/helper.rb b/lib/hash_kit/helper.rb index 5ccf910..25044bc 100644 --- a/lib/hash_kit/helper.rb +++ b/lib/hash_kit/helper.rb @@ -3,6 +3,20 @@ module HashKit # Hash kit Helper class class Helper + INDIFFERENT_PROC = proc do |h, k| + case k + when Symbol + # Symbol#name returns a frozen string without allocating (Ruby 3.0+) + str = k.name + h[str] if h.key?(str) + when String + sym = k.to_sym + h[sym] if h.key?(sym) + else + h[k.to_s] + end + end + # This method is called to make a hash allow indifferent access (it will # accept both strings & symbols for a valid key). def indifferent!(hash) @@ -10,24 +24,15 @@ def indifferent!(hash) # Set the default proc to allow the key to be either string or symbol if # a matching key is found. - hash.default_proc = proc do |h, k| - if h.key?(k.to_s) - h[k.to_s] - elsif h.key?(k.to_sym) - h[k.to_sym] - else - nil - end - end + hash.default_proc = INDIFFERENT_PROC # Recursively process any child hashes - hash.each do |key,value| - unless hash[key].nil? - if hash[key].is_a?(Hash) - indifferent!(hash[key]) - elsif hash[key].is_a?(Array) - indifferent_array!(hash[key]) - end + hash.each_value do |value| + case value + when Hash + indifferent!(value) + when Array + indifferent_array!(value) end end @@ -38,9 +43,10 @@ def indifferent_array!(array) return unless array.is_a?(Array) array.each do |i| - if i.is_a?(Hash) + case i + when Hash indifferent!(i) - elsif i.is_a?(Array) + when Array indifferent_array!(i) end end