Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a PermissiveRespondTo extension for Mashes #499

Merged
merged 1 commit into from Nov 18, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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