diff options
author | Bobby McDonald <BobbyMcWho@users.noreply.github.com> | 2019-11-18 17:04:37 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-11-18 17:04:37 -0500 |
commit | d5f253963731f7b2ef96ba415ecad22c89e22d28 (patch) | |
tree | f2a772ddfe026f34262d3339f8efa6c351c626d8 | |
parent | 4f014e7fedad93fa2070166051947070cc8ac01c (diff) | |
parent | 15ea67ef0667546627f9a3cd4fef4457512f5880 (diff) | |
download | hashie-d5f253963731f7b2ef96ba415ecad22c89e22d28.tar.gz |
Merge pull request #499 from michaelherold/permissive-respond-to
Add a PermissiveRespondTo extension for Mashes
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | Gemfile | 1 | ||||
-rw-r--r-- | README.md | 24 | ||||
-rwxr-xr-x | benchmarks/permissive_respond_to.rb | 44 | ||||
-rw-r--r-- | lib/hashie.rb | 1 | ||||
-rw-r--r-- | lib/hashie/extensions/mash/permissive_respond_to.rb | 61 | ||||
-rw-r--r-- | spec/hashie/extensions/mash/permissive_respond_to_spec.rb | 44 |
7 files changed, 176 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 23892eb..1b46c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ scheme are considered to be bugs. ### Added +* [#499](https://github.com/hashie/hashie/pull/499): Add `Hashie::Extensions::Mash::PermissiveRespondTo` to make specific subclasses of Mash fully respond to messages for use with `SimpleDelegator` - [@michaelherold](https://github.com/michaelherold). * Your contribution here. ### Changed @@ -4,6 +4,7 @@ gemspec group :development do gem 'benchmark-ips' + gem 'benchmark-memory' gem 'guard', '~> 2.6.1' gem 'guard-rspec', '~> 4.3.1', require: false gem 'guard-yield', '~> 0.1.0', require: false @@ -658,6 +658,30 @@ mash['string_key'] #=> 'string' mash[:string_key] #=> 'string' ``` +### Mash Extension: PermissiveRespondTo + +By default, Mash only states that it responds to built-in methods, affixed methods (e.g. setters, underbangs, etc.), and keys that it currently contains. That means it won't state that it responds to a getter for an unset key, as in the following example: + +```ruby +mash = Hashie::Mash.new(a: 1) +mash.respond_to? :b #=> false +``` + +This means that by default Mash is not a perfect match for use with a SimpleDelegator since the delegator will not forward messages for unset keys to the Mash even though it can handle them. + +In order to have a SimpleDelegator-compatible Mash, you can use the `PermissiveRespondTo` extension to make Mash respond to anything. + +```ruby +class PermissiveMash < Hashie::Mash + include Hashie::Extensions::Mash::PermissiveRespondTo +end + +mash = PermissiveMash.new(a: 1) +mash.respond_to? :b #=> true +``` + +This comes at the cost of approximately 20% performance for initialization and setters and 19KB of permanent memory growth for each such class that you create. + ### Mash Extension: SafeAssignment This extension can be mixed into a Mash to guard the attempted overwriting of methods by property setters. When mixed in, the Mash will raise an `ArgumentError` if you attempt to write a property with the same name as an existing method. diff --git a/benchmarks/permissive_respond_to.rb b/benchmarks/permissive_respond_to.rb new file mode 100755 index 0000000..e7e894e --- /dev/null +++ b/benchmarks/permissive_respond_to.rb @@ -0,0 +1,44 @@ +#!/usr/bin/env ruby + +$LOAD_PATH.unshift File.expand_path(File.join('..', 'lib'), __dir__) + +require 'hashie' +require 'benchmark/ips' +require 'benchmark/memory' + +permissive = Class.new(Hashie::Mash) + +Benchmark.memory do |x| + x.report('Default') {} + x.report('Make permissive') do + permissive.include Hashie::Extensions::Mash::PermissiveRespondTo + end +end + +class PermissiveMash < Hashie::Mash + include Hashie::Extensions::Mash::PermissiveRespondTo +end + +Benchmark.ips do |x| + x.report('Mash.new') { Hashie::Mash.new(a: 1) } + x.report('Permissive.new') { PermissiveMash.new(a: 1) } + + x.compare! +end + +Benchmark.ips do |x| + x.report('Mash#attr=') { Hashie::Mash.new.a = 1 } + x.report('Permissive#attr=') { PermissiveMash.new.a = 1 } + + x.compare! +end + +mash = Hashie::Mash.new(a: 1) +permissive = PermissiveMash.new(a: 1) + +Benchmark.ips do |x| + x.report('Mash#attr= x2') { mash.a = 1 } + x.report('Permissive#attr= x2') { permissive.a = 1 } + + x.compare! +end diff --git a/lib/hashie.rb b/lib/hashie.rb index a3fc11c..7f88ed4 100644 --- a/lib/hashie.rb +++ b/lib/hashie.rb @@ -45,6 +45,7 @@ module Hashie module Mash autoload :KeepOriginalKeys, 'hashie/extensions/mash/keep_original_keys' + autoload :PermissiveRespondTo, 'hashie/extensions/mash/permissive_respond_to' autoload :SafeAssignment, 'hashie/extensions/mash/safe_assignment' autoload :SymbolizeKeys, 'hashie/extensions/mash/symbolize_keys' autoload :DefineAccessors, 'hashie/extensions/mash/define_accessors' diff --git a/lib/hashie/extensions/mash/permissive_respond_to.rb b/lib/hashie/extensions/mash/permissive_respond_to.rb new file mode 100644 index 0000000..5f8c231 --- /dev/null +++ b/lib/hashie/extensions/mash/permissive_respond_to.rb @@ -0,0 +1,61 @@ +module Hashie + module Extensions + module Mash + # Allow a Mash to properly respond to everything + # + # By default, Mashes only say they respond to methods for keys that exist + # in their key set or any of the affix methods (e.g. setter, underbang, + # etc.). This causes issues when you try to use them within a + # SimpleDelegator or bind to a method for a key that is unset. + # + # This extension allows a Mash to properly respond to `respond_to?` and + # `method` for keys that have not yet been set. This enables full + # compatibility with SimpleDelegator and thunk-oriented programming. + # + # There is a trade-off with this extension: it will run slower than a + # regular Mash; insertions and initializations with keys run approximately + # 20% slower and cost approximately 19KB of memory per class that you + # make permissive. + # + # @api public + # @example Make a new, permissively responding Mash subclass + # class PermissiveMash < Hashie::Mash + # include Hashie::Extensions::Mash::PermissiveRespondTo + # end + # + # mash = PermissiveMash.new(a: 1) + # mash.respond_to? :b #=> true + module PermissiveRespondTo + # The Ruby hook for behavior when including the module + # + # @api private + # @private + # @return void + def self.included(base) + base.instance_variable_set :@_method_cache, base.instance_methods + base.define_singleton_method(:method_cache) { @_method_cache } + end + + # The Ruby hook for determining what messages a class might respond to + # + # @api private + # @private + def respond_to_missing?(_method_name, _include_private = false) + true + end + + private + + # Override the Mash logging behavior to account for permissiveness + # + # @api private + # @private + def log_collision?(method_key) + self.class.method_cache.include?(method_key) && + !self.class.disable_warnings?(method_key) && + !(regular_key?(method_key) || regular_key?(method_key.to_s)) + end + end + end + end +end diff --git a/spec/hashie/extensions/mash/permissive_respond_to_spec.rb b/spec/hashie/extensions/mash/permissive_respond_to_spec.rb new file mode 100644 index 0000000..189f94a --- /dev/null +++ b/spec/hashie/extensions/mash/permissive_respond_to_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +RSpec.describe Hashie::Extensions::Mash::PermissiveRespondTo do + class PermissiveMash < Hashie::Mash + include Hashie::Extensions::Mash::PermissiveRespondTo + end + + it 'allows you to bind to unset getters' do + mash = PermissiveMash.new(a: 1) + other_mash = PermissiveMash.new(b: 2) + + expect { mash.method(:b) }.not_to raise_error + expect(mash.method(:b).unbind.bind(other_mash).call).to eq 2 + end + + it 'works properly with SimpleDelegator' do + delegator = Class.new(SimpleDelegator) do + def initialize(hash) + super(PermissiveMash.new(hash)) + end + end + + foo = delegator.new(a: 1) + + expect(foo.a).to eq 1 + expect { foo.b }.not_to raise_error + end + + context 'warnings' do + include_context 'with a logger' + + it 'does not log a collision when setting normal keys' do + PermissiveMash.new(a: 1) + + expect(logger_output).to be_empty + end + + it 'logs a collision with a built-in method' do + PermissiveMash.new(zip: 1) + + expect(logger_output).to match('PermissiveMash#zip defined in Enumerable') + end + end +end |