hashie/lib/hashie/mash.rb

367 lines
10 KiB
Ruby

require 'hashie/hash'
require 'hashie/array'
require 'hashie/utils'
require 'hashie/logger'
module Hashie
# Mash allows you to create pseudo-objects that have method-like
# accessors for hash keys. This is useful for such implementations
# as an API-accessing library that wants to fake robust objects
# without the overhead of actually doing so. Think of it as OpenStruct
# with some additional goodies.
#
# A Mash will look at the methods you pass it and perform operations
# based on the following rules:
#
# * No punctuation: Returns the value of the hash for that key, or nil if none exists.
# * Assignment (<tt>=</tt>): Sets the attribute of the given method name.
# * Existence (<tt>?</tt>): Returns true or false depending on whether that key has been set.
# * Bang (<tt>!</tt>): Forces the existence of this key, used for deep Mashes. Think of it
# as "touch" for mashes.
# * Under Bang (<tt>_</tt>): Like Bang, but returns a new Mash rather than creating a key.
# Used to test existance in deep Mashes.
#
# == Basic Example
#
# mash = Mash.new
# mash.name? # => false
# mash.name = "Bob"
# mash.name # => "Bob"
# mash.name? # => true
#
# == Hash Conversion Example
#
# hash = {:a => {:b => 23, :d => {:e => "abc"}}, :f => [{:g => 44, :h => 29}, 12]}
# mash = Mash.new(hash)
# mash.a.b # => 23
# mash.a.d.e # => "abc"
# mash.f.first.g # => 44
# mash.f.last # => 12
#
# == Bang Example
#
# mash = Mash.new
# mash.author # => nil
# mash.author! # => <Mash>
#
# mash = Mash.new
# mash.author!.name = "Michael Bleigh"
# mash.author # => <Mash name="Michael Bleigh">
#
# == Under Bang Example
#
# mash = Mash.new
# mash.author # => nil
# mash.author_ # => <Mash>
# mash.author_.name # => nil
#
# mash = Mash.new
# mash.author_.name = "Michael Bleigh" (assigned to temp object)
# mash.author # => <Mash>
#
class Mash < Hash
include Hashie::Extensions::PrettyInspect
include Hashie::Extensions::RubyVersionCheck
ALLOWED_SUFFIXES = %w[? ! = _].freeze
class CannotDisableMashWarnings < StandardError
def initialize
super(
'You cannot disable warnings on the base Mash class. ' \
'Please subclass the Mash and disable it in the subclass.'
)
end
end
# Disable the logging of warnings based on keys conflicting keys/methods
#
# @api semipublic
# @return [void]
def self.disable_warnings
raise CannotDisableMashWarnings if self == Hashie::Mash
@disable_warnings = true
end
# Checks whether this class disables warnings for conflicting keys/methods
#
# @api semipublic
# @return [Boolean]
def self.disable_warnings?
@disable_warnings ||= false
end
# Inheritance hook that sets class configuration when inherited.
#
# @api semipublic
# @return [void]
def self.inherited(subclass)
super
subclass.disable_warnings if disable_warnings?
end
def self.load(path, options = {})
@_mashes ||= new
return @_mashes[path] if @_mashes.key?(path)
raise ArgumentError, "The following file doesn't exist: #{path}" unless File.file?(path)
parser = options.fetch(:parser) { Hashie::Extensions::Parsers::YamlErbParser }
@_mashes[path] = new(parser.perform(path, options.except(:parser))).freeze
end
def to_module(mash_method_name = :settings)
mash = self
Module.new do |m|
m.send :define_method, mash_method_name.to_sym do
mash
end
end
end
def with_accessors!
extend Hashie::Extensions::Mash::DefineAccessors
end
alias to_s inspect
# If you pass in an existing hash, it will
# convert it to a Mash including recursively
# descending into arrays and hashes, converting
# them as well.
def initialize(source_hash = nil, default = nil, &blk)
deep_update(source_hash) if source_hash
default ? super(default) : super(&blk)
end
class << self; alias [] new; end
alias regular_reader []
alias regular_writer []=
# Retrieves an attribute set in the Mash. Will convert
# any key passed in to a string before retrieving.
def custom_reader(key)
default_proc.call(self, key) if default_proc && !key?(key)
value = regular_reader(convert_key(key))
yield value if block_given?
value
end
# Sets an attribute in the Mash. Key will be converted to
# a string before it is set, and Hashes will be converted
# into Mashes for nesting purposes.
def custom_writer(key, value, convert = true) #:nodoc:
key_as_symbol = (key = convert_key(key)).to_sym
log_built_in_message(key_as_symbol) if log_collision?(key_as_symbol)
regular_writer(key, convert ? convert_value(value) : value)
end
alias [] custom_reader
alias []= custom_writer
# This is the bang method reader, it will return a new Mash
# if there isn't a value already assigned to the key requested.
def initializing_reader(key)
ck = convert_key(key)
regular_writer(ck, self.class.new) unless key?(ck)
regular_reader(ck)
end
# This is the under bang method reader, it will return a temporary new Mash
# if there isn't a value already assigned to the key requested.
def underbang_reader(key)
ck = convert_key(key)
if key?(ck)
regular_reader(ck)
else
self.class.new
end
end
def fetch(key, *args)
super(convert_key(key), *args)
end
def delete(key)
super(convert_key(key))
end
def values_at(*keys)
super(*keys.map { |key| convert_key(key) })
end
alias regular_dup dup
# Duplicates the current mash as a new mash.
def dup
self.class.new(self, default, &default_proc)
end
alias regular_key? key?
def key?(key)
super(convert_key(key))
end
alias has_key? key?
alias include? key?
alias member? key?
# Performs a deep_update on a duplicate of the
# current mash.
def deep_merge(other_hash, &blk)
dup.deep_update(other_hash, &blk)
end
alias merge deep_merge
# Recursively merges this mash with the passed
# in hash, merging each hash in the hierarchy.
def deep_update(other_hash, &blk)
other_hash.each_pair do |k, v|
key = convert_key(k)
if v.is_a?(::Hash) && key?(key) && regular_reader(key).is_a?(Mash)
custom_reader(key).deep_update(v, &blk)
else
value = convert_value(v, true)
value = convert_value(yield(key, self[k], value), true) if blk && key?(k)
custom_writer(key, value, false)
end
end
self
end
alias deep_merge! deep_update
alias update deep_update
alias merge! update
# Assigns a value to a key
def assign_property(name, value)
self[name] = value
end
# Performs a shallow_update on a duplicate of the current mash
def shallow_merge(other_hash)
dup.shallow_update(other_hash)
end
# Merges (non-recursively) the hash from the argument,
# changing the receiving hash
def shallow_update(other_hash)
other_hash.each_pair do |k, v|
regular_writer(convert_key(k), convert_value(v, true))
end
self
end
def replace(other_hash)
(keys - other_hash.keys).each { |key| delete(key) }
other_hash.each { |key, value| self[key] = value }
self
end
def respond_to_missing?(method_name, *args)
return true if key?(method_name)
suffix = method_suffix(method_name)
if suffix
true
else
super
end
end
def prefix_method?(method_name)
method_name = method_name.to_s
method_name.end_with?(*ALLOWED_SUFFIXES) && key?(method_name.chop)
end
def method_missing(method_name, *args, &blk) # rubocop:disable Style/MethodMissing
return self.[](method_name, &blk) if key?(method_name)
name, suffix = method_name_and_suffix(method_name)
case suffix
when '='.freeze
assign_property(name, args.first)
when '?'.freeze
!!self[name]
when '!'.freeze
initializing_reader(name)
when '_'.freeze
underbang_reader(name)
else
self[method_name]
end
end
# play nice with ActiveSupport Array#extract_options!
def extractable_options?
true
end
# another ActiveSupport method, see issue #270
def reverse_merge(other_hash)
self.class.new(other_hash).merge(self)
end
with_minimum_ruby('2.3.0') do
def dig(*keys)
super(*keys.map { |key| convert_key(key) })
end
end
protected
def method_name_and_suffix(method_name)
method_name = method_name.to_s
if method_name.end_with?(*ALLOWED_SUFFIXES)
[method_name[0..-2], method_name[-1]]
else
[method_name[0..-1], nil]
end
end
def method_suffix(method_name)
method_name = method_name.to_s
method_name[-1] if method_name.end_with?(*ALLOWED_SUFFIXES)
end
def convert_key(key) #:nodoc:
key.to_s
end
def convert_value(val, duping = false) #:nodoc:
case val
when self.class
val.dup
when Hash
duping ? val.dup : val
when ::Hash
val = val.dup if duping
self.class.new(val)
when Array
val.map { |e| convert_value(e) }
when ::Array
Array.new(val.map { |e| convert_value(e) })
else
val
end
end
private
def log_built_in_message(method_key)
return if self.class.disable_warnings?
method_information = Hashie::Utils.method_information(method(method_key))
Hashie.logger.warn(
'You are setting a key that conflicts with a built-in method ' \
"#{self.class}##{method_key} #{method_information}. " \
'This can cause unexpected behavior when accessing the key as a ' \
'property. You can still access the key via the #[] method.'
)
end
def log_collision?(method_key)
respond_to?(method_key) && !self.class.disable_warnings? &&
!(regular_key?(method_key) || regular_key?(method_key.to_s))
end
end
end