summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Herold <opensource@michaeljherold.com>2019-11-17 11:16:10 -0600
committerMichael Herold <opensource@michaeljherold.com>2019-11-17 11:36:31 -0600
commit15ea67ef0667546627f9a3cd4fef4457512f5880 (patch)
tree2cf6345d16a063cd0dca51eb6a0dfc8c90ea2d80
parent2846ea63a90a594ed67e3eb8ba7c5fd125909089 (diff)
downloadhashie-15ea67ef0667546627f9a3cd4fef4457512f5880.tar.gz
Add a PermissiveRespondTo extension for Mashes
By default, Mashes don't state that they respond to unset keys. This causes unexpected behavior when you try to use a Mash with a SimpleDelegator. This new extension allows you create a permissive subclass of Mash that will be fully compatible with SimpleDelegator and allow you to fully do thunk-oriented programming with Mashes. This comes with the trade-off of a ~19KB cache for each of these subclasses and a ~20% performance penalty on any of those subclasses.
-rw-r--r--CHANGELOG.md1
-rw-r--r--Gemfile1
-rw-r--r--README.md24
-rwxr-xr-xbenchmarks/permissive_respond_to.rb44
-rw-r--r--lib/hashie.rb1
-rw-r--r--lib/hashie/extensions/mash/permissive_respond_to.rb61
-rw-r--r--spec/hashie/extensions/mash/permissive_respond_to_spec.rb44
7 files changed, 176 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1408cb5..49b3aad 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
diff --git a/Gemfile b/Gemfile
index 524b968..5001638 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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
diff --git a/README.md b/README.md
index 9bbaebc..f52f1f2 100644
--- a/README.md
+++ b/README.md
@@ -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