From 46c0ce9eabafb215e0082cee0c4897d9630d67a9 Mon Sep 17 00:00:00 2001 From: Alex Riedler Date: Fri, 3 Jul 2026 21:59:37 -0400 Subject: [PATCH] fix!: canonicalize keys, update CI, and refresh dependencies BREAKING CHANGE: - Symbol hash keys are now canonicalized to strings internally. This affects #to_h, #to_hash, #inspect, and unmarshalled data that was originally created with symbol keys. - Minimum supported Ruby version is now 3.2. - StrictStruct#to_h / #to_hash now return a shallow copy instead of the internal mutable hash. Other changes: - Fix cache collisions in IndifferentLookup when a hash contains both symbol and string keys; cached nested structs now have stable identity. - Fix README typos ("ResouceStruct") and GitHub URL placeholder. - Remove unused bindir/executables from gemspec; add MFA metadata. - Update CI matrix to Ruby 3.2, 3.3, 3.4, and 4.0.5; use actions/checkout@v4. - Update CodeQL actions to v3. - Update RuboCop to ~> 1.75 and rake to ~> 13.2; enable NewCops and target Ruby 3.2. - Add regression tests for symbol/string key collisions and fix spec typos. - GitHub Actions pull_request trigger pushes to PR branches re-run CI. --- .github/workflows/codeql-analysis.yml | 6 +- .github/workflows/default.yml | 5 +- .rubocop.yml | 3 +- CHANGELOG.md | 18 +++ Gemfile | 5 +- Gemfile.lock | 112 ++++++++++++------ README.md | 6 +- .../extensions/indifferent_lookup.rb | 81 +++++++------ lib/resource_struct/flex_struct.rb | 13 +- lib/resource_struct/strict_struct.rb | 9 +- lib/resource_struct/version.rb | 2 +- resource-struct.gemspec | 5 +- spec/resource_struct/flex_struct_spec.rb | 30 ++++- spec/resource_struct/strict_struct_spec.rb | 23 +++- 14 files changed, 212 insertions(+), 106 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 78d3911..4e2238c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,12 +24,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index a0e25fb..383dff0 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -5,7 +5,6 @@ on: branches: [master] pull_request: branches: [master] - types: [opened, reopened] jobs: test: @@ -13,10 +12,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['2.7', '3.0', '3.1'] + ruby-version: ['3.3', '3.4', '4.0'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.rubocop.yml b/.rubocop.yml index ff7e179..2159154 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,6 @@ AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.3 + NewCops: enable Style/StringLiterals: Enabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 6092765..38d645c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ ## [Unreleased] +## [0.5.0] - 2026-07-03 +### Breaking +- Symbol hash keys are now canonicalized to strings internally. This means: + - `Struct#to_h`, `#to_hash`, and `#inspect` return string keys even when the input hash used symbols. + - Hashes containing both a symbol and string key for the same logical property (e.g. `{ foo: 1, "foo" => 2 }`) will keep only one value after canonicalization. + - Marshalled data from previous versions that used symbol keys will load with string keys. +- Raised minimum Ruby version to 3.3. +- `StrictStruct#to_h` / `#to_hash` now return a shallow copy instead of the internal mutable hash. + +### Fixed +- Canonicalize symbol keys to strings internally so symbol/string key collisions are handled consistently and cached nested structs have stable identity. +- README typos (`ResouceStruct`) and GitHub URL placeholder. +- Removed unused `bindir` / `executables` configuration from the gemspec. +- Fixed GitHub Actions pull_request trigger so pushes to PR branches (including force pushes) re-run CI. + +### Changed +- Updated RuboCop and CI to current Ruby versions (3.3, 3.4, 4.0) and action versions. + ## [0.4.0] - 2022-01-09 ### Feature - Better support for `ArgumentError` on `FlexStruct` diff --git a/Gemfile b/Gemfile index 899f745..8c62f73 100644 --- a/Gemfile +++ b/Gemfile @@ -5,10 +5,11 @@ source "https://rubygems.org" # Specify your gem's dependencies in resource-struct.gemspec gemspec -gem "rake", "~> 13.0" +gem "irb" +gem "rake", "~> 13.2" gem "rspec", "~> 3.0" -gem "rubocop", "~> 1.21" +gem "rubocop", "~> 1.75" gem "rubocop-rake" gem "rubocop-rspec" diff --git a/Gemfile.lock b/Gemfile.lock index 10e80d4..20ad085 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,61 +1,99 @@ PATH remote: . specs: - resource-struct (0.4.0) + resource-struct (0.5.0) GEM remote: https://rubygems.org/ specs: - ast (2.4.2) - diff-lcs (1.4.4) - parallel (1.21.0) - parser (3.0.3.2) + ast (2.4.3) + diff-lcs (1.6.2) + erb (6.0.4) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.20.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + parallel (2.1.0) + parser (3.3.11.1) ast (~> 2.4.1) - rainbow (3.0.0) - rake (13.0.6) - regexp_parser (2.2.0) - rexml (3.3.9) - rspec (3.10.0) - rspec-core (~> 3.10.0) - rspec-expectations (~> 3.10.0) - rspec-mocks (~> 3.10.0) - rspec-core (3.10.1) - rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-mocks (3.10.2) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-support (3.10.3) - rubocop (1.23.0) - parallel (~> 1.10) - parser (>= 3.0.0.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.88.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml - rubocop-ast (>= 1.12.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.15.0) - parser (>= 3.0.1.1) - rubocop-rake (0.6.0) - rubocop (~> 1.0) - rubocop-rspec (2.6.0) - rubocop (~> 1.19) - ruby-progressbar (1.11.0) - unicode-display_width (2.1.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + ruby-progressbar (1.13.0) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) PLATFORMS + arm64-darwin-25 x86_64-darwin-18 x86_64-darwin-19 x86_64-linux DEPENDENCIES - rake (~> 13.0) + irb + rake (~> 13.2) resource-struct! rspec (~> 3.0) - rubocop (~> 1.21) + rubocop (~> 1.75) rubocop-rake rubocop-rspec diff --git a/README.md b/README.md index 74400b4..3629872 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ This is a gem for working with JSON resources from a network source with indiffe Instead of overriding Hash implementation, this wraps a Hash with indifferent access (by symbol or string keys). This makes it fast at runtime, while still providing the necessary lookup method of choice. -There are two types `ResouceStruct::StrictStruct` and `ResourceStruct::FlexStruct`. +There are two types `ResourceStruct::StrictStruct` and `ResourceStruct::FlexStruct`. `ResourceStruct::StrictStruct` provides a way of wrapping a Hash such that accesses to invalid keys will raise an exception through the method lookup method; it also is immutable. -`ResouceStruct::FlexStruct` provides a way of wrapping a Hash such that it returns nil instead of raising an exception when the key is not present in the hash. +`ResourceStruct::FlexStruct` provides a way of wrapping a Hash such that it returns nil instead of raising an exception when the key is not present in the hash. ## Installation @@ -71,7 +71,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/resource-struct. +Bug reports and pull requests are welcome on GitHub at https://github.com/AlexRiedler/resource-struct. ## License diff --git a/lib/resource_struct/extensions/indifferent_lookup.rb b/lib/resource_struct/extensions/indifferent_lookup.rb index bbd8795..a5a8b88 100644 --- a/lib/resource_struct/extensions/indifferent_lookup.rb +++ b/lib/resource_struct/extensions/indifferent_lookup.rb @@ -13,10 +13,11 @@ module IndifferentLookup def_delegators :@hash, :to_h, :to_hash, :to_s, :as_json, :to_json def initialize(hash = {}) - @hash = hash || {} - @ro_struct = {} + hash = {} if hash.nil? + raise ::ArgumentError, "first argument must be a Hash, found #{hash.class.name}" unless hash.is_a?(Hash) - raise ::ArgumentError, "first argument must be a Hash, found #{@hash.class.name}" unless @hash.is_a?(Hash) + @hash = ___canonicalize_hash(hash) + @ro_struct = {} end def inspect @@ -24,24 +25,23 @@ def inspect end def ==(other) - other.is_a?(Hash) && ___all_keys_equal(other) || - (other.is_a?(LooseStruct) || other.is_a?(FirmStruct)) && - ___all_keys_equal(other.instance_variable_get(:@hash)) + other_hash = case other + when Hash then other + when LooseStruct, FirmStruct then other.instance_variable_get(:@hash) + else return false + end + + ___all_keys_equal(other_hash) end def dig(key, *sub_keys) ckey = ___convert_key(key) - result = - if @ro_struct.key?(ckey) - @ro_struct[ckey] - elsif @hash.key?(key) - @ro_struct[ckey] = ___convert_value(@hash[key]) - elsif key.is_a?(String) && @hash.key?(key.to_sym) - @ro_struct[ckey] = ___convert_value(@hash[key.to_sym]) - elsif key.is_a?(Symbol) && @hash.key?(key.to_s) - @ro_struct[ckey] = ___convert_value(@hash[key.to_s]) - end + result = if @ro_struct.key?(ckey) + @ro_struct[ckey] + elsif @hash.key?(ckey) + @ro_struct[ckey] = ___convert_value(@hash[ckey]) + end return result if sub_keys.empty? @@ -54,18 +54,35 @@ def dig(key, *sub_keys) alias [] dig def marshal_dump - { - data: @hash - } + { data: @hash } end def marshal_load(obj) @ro_struct = {} - @hash = obj[:data] + @hash = ___canonicalize_hash(obj[:data] || {}) end private + def ___canonicalize_hash(hash) + hash.each_with_object({}) do |(key, value), memo| + memo[___convert_key(key)] = ___canonicalize_value(value) + end + end + + def ___canonicalize_value(value) + case value + when LooseStruct, FirmStruct + value.instance_variable_get(:@hash) + when ::Array + value.map { |v| ___canonicalize_value(v) } + when Hash + ___canonicalize_hash(value) + else + value + end + end + def ___convert_value(value) case value when ::Array @@ -78,9 +95,7 @@ def ___convert_value(value) end def ___key?(key) - @hash.key?(key) || - @hash.key?(___convert_key(key)) || - key.is_a?(String) && @hash.key?(key.to_sym) + @hash.key?(___convert_key(key)) end def ___convert_key(key) @@ -90,17 +105,15 @@ def ___convert_key(key) def ___all_keys_equal(other) return false unless @hash.count == other.count - @hash.reduce(true) do |acc, (k, _)| - value = self[k] - if other.key?(k) - acc && value == other[k] - elsif k.is_a?(String) - ck = k.to_sym - acc && other.key?(ck) && value == other[ck] - else - ck = ___convert_key(k) - acc && other.key?(ck) && value == other[ck] - end + @hash.all? do |k, _| + other_value = if other.key?(k) + other[k] + elsif k.is_a?(String) && other.key?(k.to_sym) + other[k.to_sym] + else + return false + end + self[k] == other_value end end end diff --git a/lib/resource_struct/flex_struct.rb b/lib/resource_struct/flex_struct.rb index 7db655a..2cb3ba6 100644 --- a/lib/resource_struct/flex_struct.rb +++ b/lib/resource_struct/flex_struct.rb @@ -22,18 +22,7 @@ class FlexStruct def []=(key, value) ckey = ___convert_key(key) @ro_struct.delete(ckey) - - value = value.instance_variable_get(:@hash) if value.is_a?(FlexStruct) || value.is_a?(StrictStruct) - - if @hash.key?(key) - @hash[key] = value - elsif key.is_a?(String) && @hash.key?(key.to_sym) - @hash[key.to_sym] = value - elsif key.is_a?(Symbol) && @hash.key?(key.to_s) - @hash[key.to_s] = value - else - @hash[key] = value - end + @hash[ckey] = ___canonicalize_value(value) end def method_missing(name, *args) diff --git a/lib/resource_struct/strict_struct.rb b/lib/resource_struct/strict_struct.rb index e1a36cf..5e032b0 100644 --- a/lib/resource_struct/strict_struct.rb +++ b/lib/resource_struct/strict_struct.rb @@ -22,7 +22,14 @@ module ResourceStruct class StrictStruct include ::ResourceStruct::Extensions::IndifferentLookup - def method_missing(name, *args, &blk) + # StrictStruct is documented as immutable. Return a shallow copy so callers + # cannot mutate the internal canonicalized hash through #to_h / #to_hash. + def to_h + @hash.dup + end + alias to_hash to_h + + def method_missing(name, *args, &) args_length = args.length return self[name] if ___key?(name) && args_length.zero? return !!self[name[...-1]] if name.end_with?("?") && args_length.zero? diff --git a/lib/resource_struct/version.rb b/lib/resource_struct/version.rb index 70737e4..22bcd2f 100644 --- a/lib/resource_struct/version.rb +++ b/lib/resource_struct/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ResourceStruct - VERSION = "0.4.0" + VERSION = "0.5.0" end diff --git a/resource-struct.gemspec b/resource-struct.gemspec index 39a7074..e9b135e 100644 --- a/resource-struct.gemspec +++ b/resource-struct.gemspec @@ -12,11 +12,12 @@ Gem::Specification.new do |spec| spec.description = "Openstruct like access without all the headaches of Hash method overrides etc..." spec.homepage = "https://github.com/AlexRiedler/resource-struct" spec.license = "MIT" - spec.required_ruby_version = ">= 2.7.0" + spec.required_ruby_version = ">= 3.3.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/AlexRiedler/resource-struct" spec.metadata["changelog_uri"] = "https://raw.githubusercontent.com/AlexRiedler/resource-struct/master/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -25,8 +26,6 @@ Gem::Specification.new do |spec| (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci|github)|appveyor)}) end end - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] # Uncomment to register a new dependency of your gem diff --git a/spec/resource_struct/flex_struct_spec.rb b/spec/resource_struct/flex_struct_spec.rb index cd7107f..e5680ea 100644 --- a/spec/resource_struct/flex_struct_spec.rb +++ b/spec/resource_struct/flex_struct_spec.rb @@ -4,9 +4,9 @@ RSpec.describe ResourceStruct::FlexStruct do shared_examples "acts like a flex struct" do - describe "#intialize" do + describe "#initialize" do context "with non hash argument" do - it "raises MethodError" do + it "raises ArgumentError" do expect { described_class.new("foo") }.to raise_error(ArgumentError) end end @@ -59,6 +59,30 @@ end end + context "with colliding symbol and string keys" do + let(:hash) do + { "foo" => 1, foo: 2, bar: [{ baz: 3, "baz" => 4 }] } + end + + it "returns consistent values regardless of access style" do + expect(struct[:foo]).to eq(struct["foo"]) + expect(struct.foo).to eq(struct["foo"]) + expect(struct[:bar][0][:baz]).to eq(struct[:bar][0]["baz"]) + expect(struct["bar"][0].baz).to eq(struct["bar"][0]["baz"]) + end + + it "returns the same nested object for symbol and string access" do + expect(struct[:bar][0]).to equal(struct["bar"][0]) + end + + it "updates both access styles when assigning by either key" do + struct[:foo] = 10 + expect(struct["foo"]).to eq(10) + struct["foo"] = 20 + expect(struct[:foo]).to eq(20) + end + end + context "with invalid params" do it "raises ArgumentError" do expect { struct.brr(1) }.to raise_error(ArgumentError) @@ -104,7 +128,7 @@ describe ".name" do context "with valid keys" do - it "return expeceted" do + it "returns expected" do expect(struct.foo).to eq(1) expect(struct.bar[0]).to eq(described_class.new({ "baz" => 2 })) expect(struct.bar[0].baz).to eq(2) diff --git a/spec/resource_struct/strict_struct_spec.rb b/spec/resource_struct/strict_struct_spec.rb index 9e3aa22..f068c8b 100644 --- a/spec/resource_struct/strict_struct_spec.rb +++ b/spec/resource_struct/strict_struct_spec.rb @@ -2,9 +2,9 @@ RSpec.describe ResourceStruct::StrictStruct do shared_examples "acts like a strict struct" do - describe "#intialize" do + describe "#initialize" do context "with non hash argument" do - it "raises MethodError" do + it "raises ArgumentError" do expect { described_class.new("foo") }.to raise_error(ArgumentError) end end @@ -56,6 +56,23 @@ expect { struct[:foo, 0] }.to raise_error(TypeError) end end + + context "with colliding symbol and string keys" do + let(:hash) do + { "foo" => 1, foo: 2, bar: [{ baz: 3, "baz" => 4 }] } + end + + it "returns consistent values regardless of access style" do + expect(struct[:foo]).to eq(struct["foo"]) + expect(struct.foo).to eq(struct["foo"]) + expect(struct[:bar][0][:baz]).to eq(struct[:bar][0]["baz"]) + expect(struct["bar"][0].baz).to eq(struct["bar"][0]["baz"]) + end + + it "returns the same nested object for symbol and string access" do + expect(struct[:bar][0]).to equal(struct["bar"][0]) + end + end end describe ".respond_to?" do @@ -90,7 +107,7 @@ describe ".name" do context "with valid keys" do - it "return expeceted" do + it "returns expected" do expect(struct.foo).to eq(1) expect(struct.bar[0]).to eq(described_class.new({ "baz" => 2 })) expect(struct.bar[0].baz).to eq(2)