hashie/lib/hashie/mash.rb

420 lines
12 KiB
Ruby

require 'hashie/hash'
require 'hashie/array'
require 'hashie/utils'
require 'hashie/logger'
require 'hashie/extensions/key_conflict_warning'
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.
# * Truthiness (<tt>?</tt>): Returns true or false depending on the truthiness of
# the attribute, or false if the key is not 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::RubyVersionCheck
extend Hashie::Extensions::KeyConflictWarning
ALLOWED_SUFFIXES = %w[? ! = _].freeze
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)
options = options.dup
parser = options.delete(:parser) { Hashie::Extensions::Parsers::YamlErbParser }
@_mashes[path] = new(parser.perform(path, options)).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
# Creates a new anonymous subclass with key conflict
# warnings disabled. You may pass an array of method
# symbols to restrict the disabled warnings to.
# Hashie::Mash.quiet.new(hash) all warnings disabled.
# Hashie::Mash.quiet(:zip).new(hash) only zip warning
# is disabled.
def self.quiet(*method_keys)
@memoized_classes ||= {}
@memoized_classes[method_keys] ||= Class.new(self) do
disable_warnings(*method_keys)
end
end
class << self; alias [] new; end
alias regular_reader []
alias regular_writer []=
# Retrieves an attribute set in the Mash. Will convert a key passed in
# as a symbol 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. Symbol keys will be converted to
# strings before being set, and Hashes will be converted into Mashes
# for nesting purposes.
def custom_writer(key, value, convert = true) #:nodoc:
log_built_in_message(key) if key.respond_to?(:to_sym) && log_collision?(key.to_sym)
regular_writer(convert_key(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
# Returns a new instance of the class it was called on, using its keys as
# values, and its values as keys. The new values and keys will always be
# strings.
def invert
self.class.new(super)
end
# Returns a new instance of the class it was called on, containing elements
# for which the given block returns false.
def reject(&blk)
self.class.new(super(&blk))
end
# Returns a new instance of the class it was called on, containing elements
# for which the given block returns true.
def select(&blk)
self.class.new(super(&blk))
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?
if with_minimum_ruby?('2.6.0')
# Performs a deep_update on a duplicate of the
# current mash.
def deep_merge(*other_hashes, &blk)
dup.deep_update(*other_hashes, &blk)
end
# Recursively merges this mash with the passed
# in hash, merging each hash in the hierarchy.
def deep_update(*other_hashes, &blk)
other_hashes.each do |other_hash|
_deep_update(other_hash, &blk)
end
self
end
else
# Performs a deep_update on a duplicate of the
# current mash.
def deep_merge(other_hash, &blk)
dup.deep_update(other_hash, &blk)
end
# Recursively merges this mash with the passed
# in hash, merging each hash in the hierarchy.
def deep_update(other_hash, &blk)
_deep_update(other_hash, &blk)
self
end
end
# Alias these lexically so they get the correctly defined
# #deep_merge and #deep_update based on ruby version.
alias merge deep_merge
alias deep_merge! deep_update
alias update deep_update
alias merge! update
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
end
private :_deep_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
def dig(*keys)
super(*keys.map { |key| convert_key(key) })
end
def transform_values(&blk)
self.class.new(super(&blk))
end
# Returns a new instance of the class it was called on, with nil values
# removed.
def compact
self.class.new(super)
end
with_minimum_ruby('2.5.0') do
def slice(*keys)
string_keys = keys.map { |key| convert_key(key) }
self.class.new(super(*string_keys))
end
def transform_keys(&blk)
self.class.new(super(&blk))
end
end
with_minimum_ruby('3.0.0') do
def except(*keys)
string_keys = keys.map { |key| convert_key(key) }
self.class.new(super(*string_keys))
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.respond_to?(:to_sym) ? key.to_s : key
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
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_key)
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)
return unless method_key.is_a?(String) || method_key.is_a?(Symbol)
return unless respond_to?(method_key)
_, suffix = method_name_and_suffix(method_key)
(!suffix || suffix == '='.freeze) &&
!self.class.disable_warnings?(method_key) &&
!(regular_key?(method_key) || regular_key?(method_key.to_s))
end
end
end