diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 0000000..96efa0b --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,38 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake +# For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby + +name: Ruby + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + test: + + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.1', '3.2', '3.3', head, jruby, jruby-head] + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, + # change this to (see https://github.com/ruby/setup-ruby#versioning): + # uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@943103cae7d3f1bb1e4951d5fcc7928b40e4b742 # 1.177.1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run tests + run: bundle exec rake diff --git a/.travis.yml b/.travis.yml index 439d317..fe7bdcb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,22 +1,23 @@ --- language: ruby rvm: - # No longer supported - # - 1.9.3 # json gem now requires Ruby ~> 2.0 + # No longer supported (but test anyways) - 2.0.0 - 2.1.10 - 2.2.10 - jruby-19mode - # Current stable supported by Travis - 2.3.8 - - 2.4.9 - - 2.5.7 - - 2.6.5 - - 2.7.0 + - 2.4.10 + # Current stable supported by Travis + - 2.5.8 + - 2.6.6 + - 2.7.2 + - 3.0.0 - jruby-9.1.9.0 # Future - ruby-head - jruby-head + - truffleruby-head sudo: false matrix: allow_failures: @@ -25,7 +26,9 @@ matrix: - rvm: 2.1.10 - rvm: 2.2.10 - rvm: 2.3.8 + - rvm: 2.4.10 - rvm: jruby-19mode # Future - rvm: ruby-head - rvm: jruby-head + - rvm: truffleruby-head diff --git a/AUTHORS.txt b/AUTHORS.txt index 9b95f9e..1c99ce9 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -3,20 +3,29 @@ Recursive-open-struct was written by these fine people: * Ben Langfeld * Beni Cherniavsky-Paskin * Cédric Felizard +* David Feldman * Edward Betts * Ewoud Kohl van Wijngaarden * Federico Aloi * fervic +* Hartley McGuire +* Igor Victor +* Ilya Umanets +* Jean Boussier * Joe Rafaniello * Kris Dekeyser +* Maple Ong * Matt Culpepper * Matthew O'Riordan * Offirmo * Pedro Sena * Peter Yeremenko * Pirate Praveen +* Richard Degenne +* Richard Degenne * Sebastian Gaul * Thiago Guimaraes * Tom Chapin * Victor Guzman * William (B.J.) Snow Orvis +* Wynn (B.J.) Snow Orvis diff --git a/CHANGELOG.md b/CHANGELOG.md index c3244d4..1fe962b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,66 @@ +2.0.0 / 2024/10/03 +================== + +* BREAKING: Restore #72, which is an API-breaking change because it changes + what data is serialized with marshalling. + +1.3.1 / 2024/10/03 +================== + +* FIX for + [#79](https://github.com/aetherknight/recursive-open-struct/issues/79): + Revert #72's change to marshalling and dumping since it is an API-breaking + change. It will be re-added in a new major version after releasing a fix. + +1.3.0 / 2024/10/01 +================== + +* [#72](https://github.com/aetherknight/recursive-open-struct/pull/72): Maple + Ong: Better handle marshalling and dumping ROS +* [#78](https://github.com/aetherknight/recursive-open-struct/pull/78): Hartley + McGuire: Add ostruct as a dependency since newer Ruby versions are going to + remove it + +1.2.2 / 2024/06/18 +================== + +* [#75](https://github.com/aetherknight/recursive-open-struct/pull/75): Richard + Degenne: Fix Ruby 3.1 `#freeze` + +1.2.1 / 2024/05/27 +================== + +* Fix a test that is flakey with JRuby + +1.2.0 / 2024/05/27 +================== + +* [#76](https://github.com/aetherknight/recursive-open-struct/pull/76): + IlyaUmanets: Add `raise_on_missing` option, causing ROS to raise + `NoMethodError` instead of returning `nil` if a field doesn't exist +* MAINT: Switched to Github Actions for CI +* MAINT: No longer officially supporting Ruby versions of 3.0.x or earlier, + updated CI to test 3.1.x, 3.2.x, 3.3.x + +1.1.3 / 2020/10/15 +================== + +* No longer officially supporting Ruby 2.4.x, but compatiblity continues. +* [#68](https://github.com/aetherknight/recursive-open-struct/pull/68): Igor + Victor: Add truffleruby-head to travis +* FIX [#67](https://github.com/aetherknight/recursive-open-struct/pull/67): + Jean Boussier: Support upstream changes to OpenStruct in ruby-head (Ruby + 3.0.0-dev) + +1.1.2 / 2020/06/20 +================== + +* FIX [#58](https://github.com/aetherknight/recursive-open-struct/pull/58): + David Feldman: Fix `[]=` so that it properly updates sub-elements +* [#58](https://github.com/aetherknight/recursive-open-struct/pull/58): + David Feldman: Make the default options configurable at the class level to + simplify adding additional options in subclasses + 1.1.1 / 2020/03/10 ================== diff --git a/README.md b/README.md index e155481..5774a1a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,23 @@ ros = RecursiveOpenStruct.new(h, preserve_original_keys: true) ros.to_h # => { 'fear' => 'is', 'the' => 'mindkiller' } ``` +### Optional: Raise error on missing attribute + +This option allows to raise an error if you try to call an attribute you didn't specify in hash + +```ruby +h = { 'fear' => 'is', 'the' => 'mindkiller' } } +ros = RecursiveOpenStruct.new(h, raise_on_missing: true) +ros.undefined # => undefined method `undefined' for # +``` + +The default behaviour returns nil + +```ruby +h = { 'fear' => 'is', 'the' => 'mindkiller' } } +ros = RecursiveOpenStruct.new(h) +ros.undefined # => nil +``` ## Installation diff --git a/lib/recursive_open_struct.rb b/lib/recursive_open_struct.rb index 03ad915..9e435fe 100644 --- a/lib/recursive_open_struct.rb +++ b/lib/recursive_open_struct.rb @@ -3,7 +3,6 @@ require 'recursive_open_struct/debug_inspect' require 'recursive_open_struct/deep_dup' -require 'recursive_open_struct/ruby_19_backport' require 'recursive_open_struct/dig' # TODO: When we care less about Rubies before 2.4.0, match OpenStruct's method @@ -14,34 +13,53 @@ # `#to_h`. class RecursiveOpenStruct < OpenStruct - include Ruby19Backport if RUBY_VERSION =~ /\A1.9/ include Dig if OpenStruct.public_instance_methods.include? :dig # TODO: deprecated, possibly remove or make optional an runtime so that it # doesn't normally pollute the public method namespace include DebugInspect - def initialize(hash=nil, args={}) + def self.default_options + { + mutate_input_hash: false, + recurse_over_arrays: false, + preserve_original_keys: false, + raise_on_missing: false + } + end + + def initialize(hash=nil, passed_options={}) hash ||= {} - @recurse_over_arrays = args.fetch(:recurse_over_arrays, false) - @preserve_original_keys = args.fetch(:preserve_original_keys, false) - @deep_dup = DeepDup.new( - recurse_over_arrays: @recurse_over_arrays, - preserve_original_keys: @preserve_original_keys - ) - @table = args.fetch(:mutate_input_hash, false) ? hash : @deep_dup.call(hash) + @options = self.class.default_options.merge!(passed_options).freeze + + @deep_dup = DeepDup.new(@options) + + @table = @options[:mutate_input_hash] ? hash : @deep_dup.call(hash) @sub_elements = {} end - def initialize_copy(orig) - super - - # deep copy the table to separate the two objects - @table = @deep_dup.call(orig.instance_variable_get(:@table)) - # Forget any memoized sub-elements + def marshal_load(attributes) + hash, @options = attributes + @deep_dup = DeepDup.new(@options) @sub_elements = {} + super(hash) + end + + def marshal_dump + [super, @options] + end + + if OpenStruct.public_instance_methods.include?(:initialize_copy) + def initialize_copy(orig) + super + + # deep copy the table to separate the two objects + @table = @deep_dup.call(@table) + # Forget any memoized sub-elements + @sub_elements = {} + end end def to_h @@ -52,17 +70,25 @@ def to_h # itself to be a "kind of" Hash. alias_method :to_hash, :to_h + # Continue supporting older rubies -- JRuby 9.1.x.x is still considered + # stable, but is based on Ruby + # 2.3.x and so uses :modifiable instead of :modifiable?. Furthermore, if + # :modifiable is private, then make :modifiable? private too. + if !OpenStruct.private_instance_methods.include?(:modifiable?) + if OpenStruct.private_instance_methods.include?(:modifiable) + alias_method :modifiable?, :modifiable + elsif OpenStruct.public_instance_methods.include?(:modifiable) + alias_method :modifiable?, :modifiable + private :modifiable? + end + end + def [](name) key_name = _get_key_from_table_(name) v = @table[key_name] if v.is_a?(Hash) - @sub_elements[key_name] ||= self.class.new( - v, - recurse_over_arrays: @recurse_over_arrays, - preserve_original_keys: @preserve_original_keys, - mutate_input_hash: true - ) - elsif v.is_a?(Array) and @recurse_over_arrays + @sub_elements[key_name] ||= _create_sub_element_(v, mutate_input_hash: true) + elsif v.is_a?(Array) and @options[:recurse_over_arrays] @sub_elements[key_name] ||= recurse_over_array(v) @sub_elements[key_name] = recurse_over_array(@sub_elements[key_name]) else @@ -70,23 +96,27 @@ def [](name) end end + if private_instance_methods.include?(:modifiable?) || public_instance_methods.include?(:modifiable?) + def []=(name, value) + key_name = _get_key_from_table_(name) + tbl = modifiable? # Ensure we are modifiable + @sub_elements.delete(key_name) + tbl[key_name] = value + end + else + def []=(name, value) + key_name = _get_key_from_table_(name) + @table[key_name] = value # raises if self is frozen in Ruby 3.0 + @sub_elements.delete(key_name) + end + end + # Makes sure ROS responds as expected on #respond_to? and #method requests def respond_to_missing?(mid, include_private = false) mname = _get_key_from_table_(mid.to_s.chomp('=').chomp('_as_a_hash')) @table.key?(mname) || super end - # Continue supporting older rubies -- JRuby 9.1.x.x is still considered - # stable, but is based on Ruby - # 2.3.x and so uses :modifiable instead of :modifiable?. Furthermore, if - # :modifiable is private, then make :modifiable? private too. - if !OpenStruct.private_instance_methods.include?(:modifiable?) - alias_method :modifiable?, :modifiable - if OpenStruct.private_instance_methods.include?(:modifiable) - private :modifiable? - end - end - # Adapted implementation of method_missing to accommodate the differences # between ROS and OS. def method_missing(mid, *args) @@ -95,13 +125,20 @@ def method_missing(mid, *args) if len != 1 raise ArgumentError, "wrong number of arguments (#{len} for 1)", caller(1) end - modifiable?[new_ostruct_member!($1.to_sym)] = args[0] + # self[$1.to_sym] = args[0] + # modifiable?[new_ostruct_member!($1.to_sym)] = args[0] + new_ostruct_member!($1.to_sym) + public_send(mid, args[0]) elsif len == 0 key = mid key = $1 if key =~ /^(.*)_as_a_hash$/ if @table.key?(_get_key_from_table_(key)) new_ostruct_member!(key) - send(mid) + public_send(mid) + elsif @options[:raise_on_missing] + err = NoMethodError.new "undefined method `#{mid}' for #{self}", mid, args + err.set_backtrace caller(1) + raise err end else err = NoMethodError.new "undefined method `#{mid}' for #{self}", mid, args @@ -110,6 +147,14 @@ def method_missing(mid, *args) end end + def freeze + @table.each_key do |key| + new_ostruct_member!(key) + end + + super + end + # TODO: Rename to new_ostruct_member! once we care less about Rubies before # 2.4.0. def new_ostruct_member(name) @@ -120,8 +165,7 @@ class << self; self; end.class_eval do self[key_name] end define_method("#{name}=") do |x| - @sub_elements.delete(key_name) - modifiable?[key_name] = x + self[key_name] = x end define_method("#{name}_as_a_hash") { @table[key_name] } end @@ -141,23 +185,36 @@ class << self; self; end.class_eval do def delete_field(name) sym = _get_key_from_table_(name) singleton_class.__send__(:remove_method, sym, "#{sym}=") rescue NoMethodError # ignore if methods not yet generated. - @sub_elements.delete sym - @table.delete sym + @sub_elements.delete(sym) + @table.delete(sym) end private + unless OpenStruct.public_instance_methods.include?(:initialize_copy) + def initialize_dup(orig) + super + # deep copy the table to separate the two objects + @table = @deep_dup.call(@table) + # Forget any memoized sub-elements + @sub_elements = {} + end + end + def _get_key_from_table_(name) return name.to_s if @table.has_key?(name.to_s) return name.to_sym if @table.has_key?(name.to_sym) name end + def _create_sub_element_(hash, **overrides) + self.class.new(hash, @options.merge(overrides)) + end + def recurse_over_array(array) array.each_with_index do |a, i| if a.is_a? Hash - array[i] = self.class.new(a, :recurse_over_arrays => true, - :mutate_input_hash => true, :preserve_original_keys => @preserve_original_keys) + array[i] = _create_sub_element_(a, mutate_input_hash: true, recurse_over_arrays: true) elsif a.is_a? Array array[i] = recurse_over_array a end diff --git a/lib/recursive_open_struct/ruby_19_backport.rb b/lib/recursive_open_struct/ruby_19_backport.rb deleted file mode 100644 index bf95790..0000000 --- a/lib/recursive_open_struct/ruby_19_backport.rb +++ /dev/null @@ -1,27 +0,0 @@ -module RecursiveOpenStruct::Ruby19Backport - # Apply fix if necessary: - # https://github.com/ruby/ruby/commit/2d952c6d16ffe06a28bb1007e2cd1410c3db2d58 - def initialize_copy(orig) - super - @table.each_key{|key| new_ostruct_member(key)} - end - - def []=(name, value) - modifiable[new_ostruct_member(name)] = value - end - - def eql?(other) - return false unless other.kind_of?(OpenStruct) - @table.eql?(other.table) - end - - def hash - @table.hash - end - - def each_pair - return to_enum(:each_pair) { @table.size } unless block_given? - @table.each_pair{|p| yield p} - end -end - diff --git a/lib/recursive_open_struct/version.rb b/lib/recursive_open_struct/version.rb index be56876..c8a9cae 100644 --- a/lib/recursive_open_struct/version.rb +++ b/lib/recursive_open_struct/version.rb @@ -3,5 +3,5 @@ require 'ostruct' class RecursiveOpenStruct < OpenStruct - VERSION = "1.1.1" + VERSION = "2.0.0" end diff --git a/recursive-open-struct.gemspec b/recursive-open-struct.gemspec index 06c99e9..f51603b 100644 --- a/recursive-open-struct.gemspec +++ b/recursive-open-struct.gemspec @@ -40,5 +40,7 @@ Gem::Specification.new do |s| s.add_development_dependency('rdoc', [">= 0"]) s.add_development_dependency('rspec', "~> 3.2") s.add_development_dependency('simplecov', [">= 0"]) + + s.add_dependency('ostruct') end diff --git a/spec/recursive_open_struct/indifferent_access_spec.rb b/spec/recursive_open_struct/indifferent_access_spec.rb index 5b86740..c62400c 100644 --- a/spec/recursive_open_struct/indifferent_access_spec.rb +++ b/spec/recursive_open_struct/indifferent_access_spec.rb @@ -160,6 +160,30 @@ end end + context 'when undefined method' do + context 'when raise_on_missing is enabled' do + subject(:recursive) { RecursiveOpenStruct.new(recursive_hash, raise_on_missing: true) } + let(:recursive_hash) { {:foo => [ {'bar' => [ { 'foo' => :bar} ] } ] } } + + specify 'raises NoMethodError' do + expect { + recursive.undefined_method + }.to raise_error(NoMethodError) + end + end + + context 'when raise_on_missing is disabled' do + context 'preserves the original keys' do + subject(:recursive) { RecursiveOpenStruct.new(recursive_hash) } + let(:recursive_hash) { {:foo => [ {'bar' => [ { 'foo' => :bar} ] } ] } } + + specify 'returns nil' do + expect(recursive.undefined_method).to be_nil + end + end + end + end + end end diff --git a/spec/recursive_open_struct/open_struct_behavior_spec.rb b/spec/recursive_open_struct/open_struct_behavior_spec.rb index cdab4eb..1739171 100644 --- a/spec/recursive_open_struct/open_struct_behavior_spec.rb +++ b/spec/recursive_open_struct/open_struct_behavior_spec.rb @@ -108,5 +108,41 @@ it { expect(subject.methods.map(&:to_sym)).to_not include :asdf= } end # describe #methods end # describe handling of arbitrary attributes + + describe "handling of freezing" do + let(:hash) { { :asdf => 'John Smith' } } + + before do + ros.freeze + end + + it "can read existing keys" do + expect(ros.asdf).to eq 'John Smith' + end + + it "cannot write new keys" do + expect { ros.new_key = 'new_value' }.to raise_error FrozenError + end + + it "cannot write existing keys" do + expect { ros.asdf = 'new_value' }.to raise_error FrozenError + end + + context "with recursive structure" do + let(:hash) { { :key => { :subkey => 42 } } } + + it "can read existing sub-elements" do + expect(ros.key.subkey).to eq 42 + end + + it "can write new sub-elements" do + expect { ros.key.new_subkey = 43 }.not_to raise_error + end + + it "can write existing sub-elements" do + expect { ros.key.subkey = 43 }.not_to raise_error + end + end + end end # describe behavior it inherits from OpenStruct end diff --git a/spec/recursive_open_struct/ostruct_2_0_0_spec.rb b/spec/recursive_open_struct/ostruct_2_0_0_spec.rb index f3472d6..ae038bc 100644 --- a/spec/recursive_open_struct/ostruct_2_0_0_spec.rb +++ b/spec/recursive_open_struct/ostruct_2_0_0_spec.rb @@ -89,7 +89,7 @@ context "hash" do it "calculates table hash" do - expect(ros.hash).to be ros.instance_variable_get('@table').hash + expect(ros.hash).to eq(ros.instance_variable_get('@table').hash) end end diff --git a/spec/recursive_open_struct/recursion_spec.rb b/spec/recursive_open_struct/recursion_spec.rb index bed2a03..4b094d5 100644 --- a/spec/recursive_open_struct/recursion_spec.rb +++ b/spec/recursive_open_struct/recursion_spec.rb @@ -28,6 +28,23 @@ expect(subject.blah_as_a_hash).to eq({ :another => 'value' }) end + it "handles sub-element replacement with dotted notation before member setup" do + expect(ros[:blah][:another]).to eql 'value' + expect(ros.methods).not_to include(:blah) + + ros.blah = { changed: 'backing' } + + expect(ros.blah.changed).to eql 'backing' + end + + it "handles being dump then loaded by Marshal" do + foo_struct = [RecursiveOpenStruct.new] + bar_struct = RecursiveOpenStruct.new(foo: foo_struct) + serialized = Marshal.dump(bar_struct) + + expect(Marshal.load(serialized).foo).to eq(foo_struct) + end + describe "handling loops in the original Hashes" do let(:h1) { { :a => 'a'} } let(:h2) { { :a => 'b', :h1 => h1 } } @@ -55,6 +72,34 @@ expect(ros.blah.blargh).to eq "Janet" end + describe 'subscript mutation notation' do + it 'handles the basic case' do + subject[:blah] = 12345 + expect(subject.blah).to eql 12345 + end + + it 'recurses properly' do + subject[:blah][:another] = 'abc' + expect(subject.blah.another).to eql 'abc' + expect(subject.blah_as_a_hash).to eql({ :another => 'abc' }) + end + + let(:diff){ { :different => 'thing' } } + + it 'can replace the entire hash' do + expect(subject.to_h).to eql(h) + subject[:blah] = diff + expect(subject.to_h).to eql({ :blah => diff }) + end + + it 'updates sub-element cache' do + expect(subject.blah.different).to be_nil + subject[:blah] = diff + expect(subject.blah.different).to eql 'thing' + expect(subject.blah_as_a_hash).to eql(diff) + end + end + context "after a sub-element has been modified" do let(:hash) do { :blah => { :blargh => "Brad" }, :some_array => [ 1, 2, 3] } @@ -145,6 +190,17 @@ let(:blah_list) { [ { :foo => '1' }, { :foo => '2' }, 'baz' ] } let(:h) { { :blah => blah_list } } + context "when dump and loaded by Marshal" do + let(:test) { RecursiveOpenStruct.new(h, :recurse_over_arrays => true) } + subject { Marshal.load(Marshal.dump(test))} + + it { expect(subject.blah.length).to eq 3 } + it { expect(subject.blah[0].foo).to eq '1' } + it { expect(subject.blah[1].foo).to eq '2' } + it { expect(subject.blah_as_a_hash).to eq blah_list } + it { expect(subject.blah[2]).to eq 'baz' } + end + context "when recursing over arrays is enabled" do subject { RecursiveOpenStruct.new(h, :recurse_over_arrays => true) }