Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6

- name: Publish to RubyGems
run: |
Expand Down
15 changes: 2 additions & 13 deletions .github/workflows/rspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
94 changes: 94 additions & 0 deletions benchmark/indifferent_bench.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions hash_kit.gemspec
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
42 changes: 24 additions & 18 deletions lib/hash_kit/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,36 @@
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
Comment thread
guille-work marked this conversation as resolved.
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)
return unless hash.is_a?(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
Comment thread
guille-work marked this conversation as resolved.
end

Expand All @@ -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
Comment thread
guille-work marked this conversation as resolved.
end
Expand Down