Skip to content

Commit

Permalink
Merge pull request #499 from michaelherold/permissive-respond-to
Browse files Browse the repository at this point in the history
Add a PermissiveRespondTo extension for Mashes
  • Loading branch information
BobbyMcWho committed Nov 18, 2019
2 parents 4f014e7 + 15ea67e commit d5f2539
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 d5f2539

Please sign in to comment.