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.
This commit is contained in:
parent
2846ea63a9
commit
15ea67ef06
|
@ -12,6 +12,7 @@ scheme are considered to be bugs.
|
||||||
|
|
||||||
### Added
|
### 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.
|
* Your contribution here.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
1
Gemfile
1
Gemfile
|
@ -4,6 +4,7 @@ gemspec
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem 'benchmark-ips'
|
gem 'benchmark-ips'
|
||||||
|
gem 'benchmark-memory'
|
||||||
gem 'guard', '~> 2.6.1'
|
gem 'guard', '~> 2.6.1'
|
||||||
gem 'guard-rspec', '~> 4.3.1', require: false
|
gem 'guard-rspec', '~> 4.3.1', require: false
|
||||||
gem 'guard-yield', '~> 0.1.0', require: false
|
gem 'guard-yield', '~> 0.1.0', require: false
|
||||||
|
|
24
README.md
24
README.md
|
@ -658,6 +658,30 @@ mash['string_key'] #=> 'string'
|
||||||
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
|
### 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.
|
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.
|
||||||
|
|
|
@ -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
|
|
@ -45,6 +45,7 @@ module Hashie
|
||||||
|
|
||||||
module Mash
|
module Mash
|
||||||
autoload :KeepOriginalKeys, 'hashie/extensions/mash/keep_original_keys'
|
autoload :KeepOriginalKeys, 'hashie/extensions/mash/keep_original_keys'
|
||||||
|
autoload :PermissiveRespondTo, 'hashie/extensions/mash/permissive_respond_to'
|
||||||
autoload :SafeAssignment, 'hashie/extensions/mash/safe_assignment'
|
autoload :SafeAssignment, 'hashie/extensions/mash/safe_assignment'
|
||||||
autoload :SymbolizeKeys, 'hashie/extensions/mash/symbolize_keys'
|
autoload :SymbolizeKeys, 'hashie/extensions/mash/symbolize_keys'
|
||||||
autoload :DefineAccessors, 'hashie/extensions/mash/define_accessors'
|
autoload :DefineAccessors, 'hashie/extensions/mash/define_accessors'
|
||||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue