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:
Michael Herold 2019-11-17 11:16:10 -06:00
parent 2846ea63a9
commit 15ea67ef06
No known key found for this signature in database
GPG Key ID: 70391C233DE2F014
7 changed files with 176 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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