mirror of
https://github.com/meineerde/rackstash.git
synced 2025-10-17 14:01:01 +00:00
533 lines
22 KiB
Ruby
533 lines
22 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Copyright 2017 Holger Just
|
|
#
|
|
# This software may be modified and distributed under the terms
|
|
# of the MIT license. See the LICENSE.txt file for details.
|
|
|
|
require 'rackstash/fields/abstract_collection'
|
|
|
|
module Rackstash
|
|
module Fields
|
|
class Hash < AbstractCollection
|
|
# @return [Set<String>] a frozen list of strings which are not allowed to
|
|
# be used as keys in this hash.
|
|
attr_reader :forbidden_keys
|
|
|
|
# @param forbidden_keys [Set<String>,::Array<String>] a list of strings
|
|
# which are not allowed to be used as keys in this hash
|
|
def initialize(forbidden_keys: EMPTY_SET)
|
|
@raw = Concurrent::Hash.new
|
|
|
|
unless forbidden_keys.is_a?(Set) &&
|
|
forbidden_keys.frozen? &&
|
|
forbidden_keys.all? { |key| String === key && key.frozen? }
|
|
forbidden_keys = Set.new(forbidden_keys) { |key| utf8_encode key }
|
|
forbidden_keys.freeze
|
|
end
|
|
|
|
@forbidden_keys = forbidden_keys
|
|
end
|
|
|
|
# Retrieve a stored value from a given `key`
|
|
#
|
|
# @param key [#to_s] the key name. We will always use it as a
|
|
# frozen UTF-8 String.
|
|
# @return [Object, nil] the current value of the field or `nil` if the
|
|
# field wasn't set (yet)
|
|
def [](key)
|
|
@raw[utf8_encode(key)]
|
|
end
|
|
|
|
# Set the value of a key to the supplied value
|
|
#
|
|
# You can set nested hashes and arrays here. The hash keys will be
|
|
# normalized as strings.
|
|
#
|
|
# @param key [#to_s] the field name. When setting the field, this name
|
|
# will be normalized as a frozen UTF-8 string.
|
|
# @param value [Proc, Object] any value which can be serialized to JSON.
|
|
# The value will be normalized before being insert so that only JSON-
|
|
# compatible objects are inserted into the Hash.
|
|
#
|
|
# @raise [ArgumentError] if you attempt to set one of the forbidden keys.
|
|
# @return [value]
|
|
def []=(key, value)
|
|
key = utf8_encode(key)
|
|
raise ArgumentError, "Forbidden field #{key}" if forbidden_key?(key)
|
|
|
|
@raw[key] = normalize(value)
|
|
end
|
|
alias store []=
|
|
|
|
# @return [::Hash] deep-transforms the hash into a plain Ruby Hash
|
|
def as_json(*)
|
|
hash = @raw.to_h
|
|
hash.each_pair do |key, value|
|
|
hash[key] = value.as_json if value.is_a?(AbstractCollection)
|
|
end
|
|
end
|
|
alias to_hash as_json
|
|
alias to_h as_json
|
|
|
|
# Removes all key-value pairs from `self`.
|
|
#
|
|
# @return [self]
|
|
def clear
|
|
@raw.clear
|
|
self
|
|
end
|
|
|
|
# Returns a new {Hash} containing the contents of `hash` and the contents
|
|
# of `self`. `hash` is normalized before being added. In contrast to
|
|
# {#merge}, this method deep-merges Hash and Array values if the existing
|
|
# and merged values are of the same type.
|
|
#
|
|
# If the value is a callable object (e.g. a proc or lambda), we will call
|
|
# it and use the returned value instead, which must then be a Hash of
|
|
# course. If any of the values of the hash is a proc, it will also be
|
|
# called and the return value will be used.
|
|
#
|
|
# If you give the optional `scope` argument, the Procs will be evaluated
|
|
# in the instance scope of this object. If you leave the `scope` empty,
|
|
# they will be called in the scope of their creation environment.
|
|
#
|
|
# The following examples are thus all equivalent:
|
|
#
|
|
# hash = Rackstash::Fields::Hash.new
|
|
#
|
|
# merged = hash.deep_merge 'foo' => 'bar'
|
|
# merged = hash.deep_merge 'foo' => -> { 'bar' }
|
|
# merged = hash.deep_merge -> { 'foo' => 'bar' }
|
|
# merged = hash.deep_merge -> { 'foo' => -> { 'bar' } }
|
|
# merged = hash.deep_merge({ 'foo' => -> { self } }, scope: 'bar')
|
|
# merged = hash.deep_merge -> { { 'foo' => -> { self } } }, scope: 'bar'
|
|
#
|
|
# Nested hashes will be deep-merged and all field names will be normalized
|
|
# to strings, even on deeper levels. Given an empty Hash, these calls
|
|
#
|
|
# hash = Rackstash::Fields::Hash('foo' => { 'bar' => 'baz' })
|
|
# merged = hash.deep_merge 'foo' => { 'bar' => 'qux', fizz' => 'buzz' }
|
|
#
|
|
# will be equivalent to
|
|
#
|
|
# merged = Rackstash::Fields::Hash('foo' => { 'bar' => 'qux', fizz' => 'buzz' })
|
|
#
|
|
# As you can see, the new `"qux"` value of the nested `"bar"` field
|
|
# overwrites the old `"baz"` value.
|
|
#
|
|
# When setting the `force` argument to `false`, we will not overwrite
|
|
# existing leaf value anymore but will just ignore the value. We will
|
|
# still attempt to merge nested Hashes and Arrays if the existing and new
|
|
# values are compatible. Thus, given an empty Hash, these calls
|
|
#
|
|
# hash = Rackstash::Fields::Hash('foo' => { 'bar' => 'baz' })
|
|
# merged = hash.deep_merge({ 'foo' => { 'bar' => 'qux', fizz' => 'buzz' } }, force: false)
|
|
#
|
|
# will be equivalent to
|
|
#
|
|
# merged = Rackstash::Fields::Hash('foo' => { 'bar' => 'baz', fizz' => 'buzz' })
|
|
#
|
|
# With `force: false` the new `"qux"` value of the nested `"bar"` field is
|
|
# ignored since it was already set. We will ignore any attempt to
|
|
# overwrite any existing non-nil value.
|
|
#
|
|
# When providing an (optional) block, it will be used for conflict
|
|
# resolution in incompatible values. Compatible `Hash`es and `Array`s will
|
|
# always be deep-merged though.
|
|
#
|
|
# @param hash (see #merge)
|
|
# @param force [Boolean] set to `true` to overwrite keys with divering
|
|
# value types, raise an `ArgumentError` when trying to set a forbidden
|
|
# field. When set to `false` we silently ignore new values if they exist
|
|
# already or are forbidden from being set.
|
|
# @param scope (see #merge)
|
|
# @yield (see #merge)
|
|
# @yieldreturn (see #merge)
|
|
# @raise [ArgumentError] if you attempt to set one of the forbidden fields
|
|
# and `force` is `true`
|
|
# @return [Rackstash::Fields::Hash] a new hash containing the merged
|
|
# key-value pairs
|
|
#
|
|
# @see #merge
|
|
# @see #deep_merge!
|
|
def deep_merge(hash, force: true, scope: nil, &block)
|
|
resolver = deep_merge_resolver(:merge, force: force, scope: scope, &block)
|
|
merge(hash, force: force, scope: scope, &resolver)
|
|
end
|
|
|
|
# Adds the contents of `hash` to `self`. `hash` is normalized before being
|
|
# added. In contrast to {#merge!}, this method deep-merges Hash and Array
|
|
# values if the existing and merged values are of the same type.
|
|
#
|
|
# If the value is a callable object (e.g. a proc or lambda), we will call
|
|
# it and use the returned value instead, which must then be a Hash of
|
|
# course. If any of the values of the hash is a proc, it will also be
|
|
# called and the return value will be used.
|
|
#
|
|
# If you give the optional `scope` argument, the Procs will be evaluated
|
|
# in the instance scope of this object. If you leave the `scope` empty,
|
|
# they will be called in the scope of their creation environment.
|
|
#
|
|
# The following examples are thus all equivalent:
|
|
#
|
|
# hash = Rackstash::Fields::Hash.new
|
|
#
|
|
# hash.deep_merge! 'foo' => 'bar'
|
|
# hash.deep_merge! 'foo' => -> { 'bar' }
|
|
# hash.deep_merge! -> { 'foo' => 'bar' }
|
|
# hash.deep_merge! -> { 'foo' => -> { 'bar' } }
|
|
# hash.deep_merge!({ 'foo' => -> { self } }, scope: 'bar')
|
|
# hash.deep_merge! -> { { 'foo' => -> { self } } }, scope: 'bar'
|
|
#
|
|
# Nested hashes will be deep-merged and all field names will be normalized
|
|
# to strings, even on deeper levels. Given an empty Hash, these calls
|
|
#
|
|
# hash = Rackstash::Fields::Hash('foo' => { 'bar' => 'baz' })
|
|
# hash.deep_merge! 'foo' => { 'bar' => 'qux', fizz' => 'buzz' }
|
|
#
|
|
# will be equivalent to
|
|
#
|
|
# hash = Rackstash::Fields::Hash('foo' => { 'bar' => 'qux', fizz' => 'buzz' })
|
|
#
|
|
# As you can see, the new `"qux"` value of the nested `"bar"` field
|
|
# overwrites the old `"baz"` value.
|
|
#
|
|
# When setting the `force` argument to `false`, we will not overwrite
|
|
# existing leaf value anymore but will just ignore the value. We will
|
|
# still attempt to merge nested Hashes and Arrays if the existing and new
|
|
# values are compatible. Thus, given an empty Hash, these calls
|
|
#
|
|
# hash = Rackstash::Fields::Hash('foo' => { 'bar' => 'baz' })
|
|
# hash.deep_merge!({ 'foo' => { 'bar' => 'qux', fizz' => 'buzz' } }, force: false)
|
|
#
|
|
# will be equivalent to
|
|
#
|
|
# hash = Rackstash::Fields::Hash({ 'foo' => { 'bar' => 'baz', fizz' => 'buzz' } })
|
|
#
|
|
# With `force: false` the new `"qux"` value of the nested `"bar"` field is
|
|
# ignored since it was already set. We will ignore any attempt to
|
|
# overwrite any existing non-nil value.
|
|
#
|
|
# When providing an (optional) block, it will be used for conflict
|
|
# resolution in incompatible values. Compatible `Hash`es and `Array`s will
|
|
# always be deep-merged though.
|
|
#
|
|
# @param hash (see #merge!)
|
|
# @param force [Boolean] set to `true` to overwrite keys with divering
|
|
# value types, raise an `ArgumentError` when trying to set a forbidden
|
|
# field. When set to `false` we silently ignore new values if they exist
|
|
# already or are forbidden from being set.
|
|
# @param scope (see #merge!)
|
|
# @yield (see #merge!)
|
|
# @yieldreturn (see #merge!)
|
|
# @raise [ArgumentError] if you attempt to set one of the forbidden fields
|
|
# and `force` is `true`
|
|
# @return [self]
|
|
#
|
|
# @see #merge!
|
|
# @see #deep_merge
|
|
def deep_merge!(hash, force: true, scope: nil, &block)
|
|
resolver = deep_merge_resolver(:merge!, force: force, scope: scope, &block)
|
|
merge!(hash, force: force, scope: scope, &resolver)
|
|
end
|
|
|
|
# @return [Boolean] `true` if the Hash contains no ley-value pairs,
|
|
# `false` otherwise.
|
|
def empty?
|
|
@raw.empty?
|
|
end
|
|
|
|
# @param key [String] The name of a key to check. This MUST be a correctly
|
|
# encoded String in order to return valid results
|
|
# @return [Boolean] `true` if the key is forbidden from being added
|
|
def forbidden_key?(key)
|
|
@forbidden_keys.include?(key)
|
|
end
|
|
|
|
# Returns true if the given key is present in `self`.
|
|
#
|
|
# h = Rackstash::Fields::Hash.new
|
|
# h.merge!({ "a" => 100, "b" => 200 })
|
|
#
|
|
# h.key?("a") #=> true
|
|
# h.key?("z") #=> false
|
|
#
|
|
# @param key [#to_s] the field name. The key will be converted to an
|
|
# UTF-8 string before being checked.
|
|
# @return [Boolean] `true` if the normalized key is present in `self`
|
|
def key?(key)
|
|
@raw.key? utf8_encode(key)
|
|
end
|
|
alias has_key? key?
|
|
alias include? key?
|
|
alias member? key?
|
|
|
|
# @return [::Array<String>] a new array populated with the keys from this
|
|
# hash.
|
|
# @see #values
|
|
def keys
|
|
@raw.keys
|
|
end
|
|
|
|
# Returns a new {Hash} containing the contents of `hash` and of
|
|
# `self`. If no block is specified, the value for entries with duplicate
|
|
# keys will be that of hash. Otherwise the value for each duplicate key
|
|
# is determined by calling the block with the `key`, its value in `self`
|
|
# and its value in `hash`.
|
|
#
|
|
# If there are any forbidden fields defined on `self`, An `ArgumentError`
|
|
# is raised when trying to set any of these. The values are ignored of
|
|
# `force` is set to `false`.
|
|
#
|
|
# If `hash` itself of any of its (deeply-nested) values is a proc, it will
|
|
# get called and its result will be used instead of it. The proc will be
|
|
# evaluated in the instance scope of `scope` if given.
|
|
#
|
|
# @param hash [::Hash<#to_s, => Proc, Object>, Rackstash::Fields::Hash, Proc]
|
|
# the hash to merge into `self`. If this is a proc, it will get called
|
|
# and its result is used instead.
|
|
# @param force [Boolean] if `true`, we overwrite existing values for
|
|
# conflicting keys but raise an `ArgumentError` when trying to set a
|
|
# forbidden key. If `false`, we silently ignore values for existing or
|
|
# forbidden keys.
|
|
# @param scope [Object, nil] if `hash` or any of its (deeply-nested)
|
|
# values is a proc, it will be called in the instance scope of this
|
|
# object (when given).
|
|
#
|
|
# @yield [key, old_val, new_val] if a block is given and there is a
|
|
# duplicate key, we call the block and use its return value as the value
|
|
# to insert
|
|
# @yieldparam key [String] the hash key
|
|
# @yieldparam old_val [Object] The existing value for `key` in `self`
|
|
# @yieldparam new_val [Object] The new normalized value for `key` in
|
|
# `hash`
|
|
# @yieldreturn [Object] the intended new value for `key` to be merged into
|
|
# `self` at `key`. The value will be normalized under the given `scope`.
|
|
# @raise [ArgumentError] if you attempt to set one of the forbidden fields
|
|
# and `force` is `true`
|
|
# @return [Rackstash::Fields::Hash] a new hash containing the merged
|
|
# key-value pairs
|
|
def merge(hash, force: true, scope: nil)
|
|
if block_given?
|
|
dup.merge!(hash, force: force, scope: scope) { |key, old_val, new_val|
|
|
yield key, old_val, new_val
|
|
}
|
|
else
|
|
dup.merge!(hash, force: force, scope: scope)
|
|
end
|
|
end
|
|
|
|
# Adds the contents of `hash` to `self`. `hash` is normalized before being
|
|
# added.
|
|
#
|
|
# If there are any forbidden keys defined on `self`, {#merge!} will raise
|
|
# an `ArgumentError` when trying to set any of these. The keys are
|
|
# silently ignored if `force` is set to `false`.
|
|
#
|
|
# If there are any conflicts, i.e. if any of the keys to be merged already
|
|
# exist in `self` we will determine the value to be added by calling the
|
|
# supplied block with the `key`, its value in `self` and its value in the
|
|
# merged `hash`.
|
|
#
|
|
# If no block was provided, the conflict resolution depends on the value
|
|
# of `force`. If `force` is `true`, we will overwrite exisging keys with
|
|
# the value from `hash`. If `force` is false, we use the existing value in
|
|
# `self` if it is not `nil`.
|
|
#
|
|
# If `hash` itself of any of its (deeply-nested) values is a callable
|
|
# object (e.g. a proc or lambda) we call it and use its normalized result
|
|
# instead of the proc.
|
|
#
|
|
# If you give the optional `scope` argument, the Procs will be evaluated
|
|
# in the instance scope of the `scope` object. If you leave the `scope`
|
|
# empty, Procs will be called in the scope of their closure (creation
|
|
# environment).
|
|
#
|
|
# @param hash [::Hash<#to_s, => Proc, Object>, Rackstash::Fields::Hash, Proc]
|
|
# the hash to merge into `self`. If this is a proc, it will get called
|
|
# and its result is used instead
|
|
# @param force [Boolean] if `true`, we overwrite existing values for
|
|
# conflicting keys but raise an `ArgumentError` when trying to set a
|
|
# forbidden key. If `false`, we silently ignore values for existing or
|
|
# forbidden keys.
|
|
# @param scope [Object, nil] if `hash` or any of its (deeply-nested)
|
|
# values is a proc, it will be called in the instance scope of this
|
|
# object (when given).
|
|
#
|
|
# @yield [key, old_val, new_val] if a block is given and there is a
|
|
# duplicate key, we call the block and use its return value as the value
|
|
# to insert
|
|
# @yieldparam key [String] the hash key
|
|
# @yieldparam old_val [Object] The existing value for `key` in `self`
|
|
# @yieldparam new_val [Object] The new normalized value for `key` in
|
|
# `hash`
|
|
# @yieldreturn [Object] the intended new value for `key` to be merged into
|
|
# `self` at `key`. The value will be normalized under the given `scope`.
|
|
# @raise [ArgumentError] if you attempt to set one of the forbidden fields
|
|
# and `force` is `true`
|
|
# @return [self]
|
|
def merge!(hash, force: true, scope: nil)
|
|
hash = implicit(normalize(hash, scope: scope, wrap: false))
|
|
|
|
if force
|
|
forbidden = @forbidden_keys & hash.keys
|
|
unless forbidden.empty?
|
|
raise ArgumentError, "Forbidden keys #{forbidden.to_a.join(', ')}"
|
|
end
|
|
else
|
|
hash = hash.reject { |k, _v| forbidden_key?(k) }
|
|
end
|
|
|
|
if block_given?
|
|
@raw.merge!(hash) { |key, old_val, new_val|
|
|
yielded = yield(key, old_val, new_val)
|
|
normalize(yielded, scope: scope)
|
|
}
|
|
elsif force
|
|
@raw.merge!(hash)
|
|
else
|
|
@raw.merge!(hash) { |_key, old_val, new_val|
|
|
old_val.nil? ? new_val : old_val
|
|
}
|
|
end
|
|
self
|
|
end
|
|
alias update merge!
|
|
|
|
# Returns a new {Hash} containing the contents of `hash` and the contents
|
|
# of `self`. `hash` is normalized before being added. In contrast to
|
|
# {#merge}, this method preserves any non-nil values of existing keys in
|
|
# `self` in the returned hash.
|
|
#
|
|
# If `hash` is a callable object (e.g. a proc or lambda), we will call
|
|
# it and use the returned value instead, which must then be a Hash of
|
|
# course. If any of the values of the hash is a proc, it will also be
|
|
# called and the return value will be used.
|
|
#
|
|
# If you give the optional `scope` argument, the Procs will be evaluated
|
|
# in the instance scope of this object. If you leave the `scope` empty,
|
|
# they will be called in the scope of their creation environment.
|
|
#
|
|
# @param hash (see #merge)
|
|
# @param scope (see #merge)
|
|
# @return [Rackstash::Fields::Hash] a new hash containing the merged
|
|
# key-value pairs
|
|
#
|
|
# @see #merge
|
|
# @see #reverse_merge!
|
|
def reverse_merge(hash, scope: nil)
|
|
merge(hash, force: false, scope: scope)
|
|
end
|
|
|
|
# Adds the contents of `hash` to `self`. `hash` is normalized before being
|
|
# added. `hash` is normalized before being added. In contrast to {#merge},
|
|
# this method preserves any non-nil values of existing keys in `self`.
|
|
#
|
|
# If `hash` is a callable object (e.g. a proc or lambda), we will call
|
|
# it and use the returned value instead, which must then be a Hash of
|
|
# course. If any of the values of the hash is a proc, it will also be
|
|
# called and the return value will be used.
|
|
#
|
|
# If you give the optional `scope` argument, the Procs will be evaluated
|
|
# in the instance scope of this object. If you leave the `scope` empty,
|
|
# they will be called in the scope of their creation environment.
|
|
#
|
|
# @param hash (see #merge!)
|
|
# @param scope (see #merge!)
|
|
# @return [self]
|
|
#
|
|
# @see #merge!
|
|
# @see #reverse_merge
|
|
def reverse_merge!(hash, scope: nil)
|
|
merge!(hash, force: false, scope: scope)
|
|
end
|
|
alias reverse_update reverse_merge!
|
|
|
|
# Set a `key` of `self` to the returned value of the passed block.
|
|
# If the key is forbidden from being set or already exists with a value
|
|
# other than `nil`, the block will not be called and the value will not be
|
|
# set.
|
|
#
|
|
# @param key [#to_s] the field name. When setting the field, this name
|
|
# will be normalized as a frozen UTF-8 string.
|
|
# @param force [Boolean] if `true`, we overwrite existing values for
|
|
# conflicting keys but raise an `ArgumentError` when trying to set a
|
|
# forbidden key. If `false`, we silently ignore values for existing or
|
|
# forbidden keys.
|
|
#
|
|
# @yield [key] if the key doesn't exist yet, we call the block and use its
|
|
# return value as the value to insert at `key`
|
|
# @yieldparam key [String] The normalized key where the value is being
|
|
# inserted
|
|
# @yieldreturn [Proc, Object] the intended new value for `key` to be
|
|
# merged into `self` at `key`.
|
|
#
|
|
# @return [Object, nil] The return value of the block or `nil` if no
|
|
# insertion happened. Note that `nil` is also a valid value to insert
|
|
# into the hash.
|
|
def set(key, force: true)
|
|
key = utf8_encode(key)
|
|
|
|
if force
|
|
raise ArgumentError, "Forbidden field #{key}" if forbidden_key?(key)
|
|
else
|
|
return if forbidden_key?(key)
|
|
return unless @raw[key].nil?
|
|
end
|
|
|
|
@raw[key] = normalize(yield(key))
|
|
end
|
|
|
|
# @return [::Array] a new array populated with the values from this hash.
|
|
# @see #keys
|
|
def values
|
|
@raw.values
|
|
end
|
|
|
|
private
|
|
|
|
# Converts an object to a Hash using `to_hash`. Raise TypeError if this
|
|
# is not possible.
|
|
#
|
|
# @param obj [#to_hash]
|
|
# @return [Hash]
|
|
# @raise [TypeError] of `obj` doesn't respond to `to_hash`
|
|
def implicit(obj)
|
|
return obj.to_hash if obj.respond_to?(:to_hash)
|
|
raise TypeError, "no implicit conversion of #{obj.class} into Hash"
|
|
end
|
|
|
|
# @param merge_method [Symbol] the name of a method used for a nested
|
|
# merge operation, usually either `:merge` or `:merge!`
|
|
# @param force [Boolean] set to `true` to overwrite keys with divering
|
|
# value types, or `false` to silently ignore the new value
|
|
# @return [Lambda] a resolver block for deep-merging a hash.
|
|
def deep_merge_resolver(merge_method, force: true, scope: nil)
|
|
resolver = lambda do |key, old_val, new_val|
|
|
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
old_val.public_send(merge_method, new_val, force: force, &resolver)
|
|
elsif old_val.is_a?(Array) && new_val.is_a?(Array)
|
|
old_val.public_send(merge_method, new_val)
|
|
elsif block_given?
|
|
value = yield(key, old_val, new_val)
|
|
normalize(value, scope: scope)
|
|
elsif force
|
|
new_val
|
|
else
|
|
old_val.nil? ? new_val : old_val
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# @param hash [::Hash, Hash, #to_hash]
|
|
# @param forbidden_keys [Set<String>,::Array<String>] a list of strings
|
|
# which are not allowed to be used as keys in this hash
|
|
# @return [Hash]
|
|
def self.Hash(hash, forbidden_keys: EMPTY_SET)
|
|
Rackstash::Fields::Hash.new(forbidden_keys: forbidden_keys).merge!(hash)
|
|
end
|
|
end
|
|
end
|