Skip to content

Commit

Permalink
Add a PermissiveRespondTo extension for Mashes
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
michaelherold committed Nov 17, 2019
1 parent 16ffb31 commit 9cbf3b1
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions 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
1 change: 1 addition & 0 deletions lib/hashie.rb
Expand Up @@ -45,6 +45,7 @@ module Dash

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'
Expand Down
61 changes: 61 additions & 0 deletions 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
44 changes: 44 additions & 0 deletions 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

0 comments on commit 9cbf3b1

Please sign in to comment.