mirror of
https://github.com/meineerde/rackstash.git
synced 2025-10-17 14:01:01 +00:00
Add Rackstash::Fields::Hash#deep_merge! and deep_merge
These methods are useful convenience methods to add (nested) fields to a hash while optionally retaining existing values. Usually, `#deep_merge!` or `#set` will be the commonly used ways to set fields to a hash.
This commit is contained in:
parent
4a185096d6
commit
22d1cdb646
@ -70,6 +70,142 @@ module Rackstash
|
||||
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:
|
||||
#
|
||||
# empty_hash.deep_merge 'foo' => 'bar'
|
||||
# empty_hash.deep_merge 'foo' => -> { 'bar' }
|
||||
# empty_hash.deep_merge -> { 'foo' => 'bar' }
|
||||
# empty_hash.deep_merge -> { 'foo' => -> { 'bar' } }
|
||||
# empty_hash.deep_merge({ 'foo' => -> { self } }, scope: 'bar')
|
||||
# empty_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
|
||||
#
|
||||
# empty_hash.merge! 'foo' => { 'bar' => 'baz' }
|
||||
# empty_hash.deep_merge 'foo' => { 'bar' => 'qux', fizz' => 'buzz' }
|
||||
#
|
||||
# will be equivalent to a single call of
|
||||
#
|
||||
# empty_hash.deep_merge '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
|
||||
#
|
||||
# empty_hash.merge!({ 'foo' => { 'bar' => 'baz' } }, force: false)
|
||||
# empty_hash.deep_merge({ 'foo' => { 'bar' => 'qux', fizz' => 'buzz' } }, force: false)
|
||||
#
|
||||
# will be equivalent to a single call of
|
||||
#
|
||||
# empty_hash.deep_merge({ 'foo' => { 'bar' => 'baz', fizz' => 'buzz' } })
|
||||
#
|
||||
# With `force: false` the new `"qux"` value of the nested `"bar"` field is
|
||||
# ignored since it was already set.
|
||||
#
|
||||
# @param hash [::Hash<#to_s, => Proc, Object>, Rackstash::Fields::Hash, Proc]
|
||||
# @param force [Boolean] `true` to raise an `ArgumentError` when trying to
|
||||
# set a forbidden key, `false` to silently ingnore these key-value pairs
|
||||
# @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).
|
||||
# @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)
|
||||
resolver = deep_merge_resolver(:merge, force: force)
|
||||
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:
|
||||
#
|
||||
# empty_hash.deep_merge! 'foo' => 'bar'
|
||||
# empty_hash.deep_merge! 'foo' => -> { 'bar' }
|
||||
# empty_hash.deep_merge! -> { 'foo' => 'bar' }
|
||||
# empty_hash.deep_merge! -> { 'foo' => -> { 'bar' } }
|
||||
# empty_hash.deep_merge!({ 'foo' => -> { self } }, scope: 'bar')
|
||||
# empty_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
|
||||
#
|
||||
# empty_hash.merge! 'foo' => { 'bar' => 'baz' }
|
||||
# empty_hash.deep_merge! 'foo' => { 'bar' => 'qux', fizz' => 'buzz' }
|
||||
#
|
||||
# will be equivalent to a single call of
|
||||
#
|
||||
# empty_hash.deep_merge! '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
|
||||
#
|
||||
# empty_hash.merge!({ 'foo' => { 'bar' => 'baz' } }, force: false)
|
||||
# empty_hash.deep_merge!({ 'foo' => { 'bar' => 'qux', fizz' => 'buzz' } }, force: false)
|
||||
#
|
||||
# will be equivalent to a single call of
|
||||
#
|
||||
# empty_hash.deep_merge!({ 'foo' => { 'bar' => 'baz', fizz' => 'buzz' } })
|
||||
#
|
||||
# With `force: false` the new `"qux"` value of the nested `"bar"` field is
|
||||
# ignored since it was already set.
|
||||
#
|
||||
# @param hash [::Hash<#to_s, => Proc, Object>, Rackstash::Fields::Hash, Proc]
|
||||
# @param force [Boolean] `true` to raise an `ArgumentError` when trying to
|
||||
# set a forbidden key, `false` to silently ingnore these key-value pairs
|
||||
# @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).
|
||||
# @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)
|
||||
resolver = deep_merge_resolver(:merge!, force: force)
|
||||
merge!(hash, force: force, scope: scope, &resolver)
|
||||
end
|
||||
|
||||
# @return [Boolean] `true` if the Hash contains no ley-value pairs,
|
||||
# `false` otherwise.
|
||||
def empty?
|
||||
@ -246,6 +382,23 @@ module Rackstash
|
||||
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)
|
||||
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)
|
||||
else
|
||||
force || old_val == nil ? new_val : old_val
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.Hash(raw, forbidden_keys: EMPTY_SET)
|
||||
|
||||
@ -160,6 +160,161 @@ describe Rackstash::Fields::Hash do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#deep_merge' do
|
||||
# This works almost exactly the same as deep_merge! although we don't repeat
|
||||
# all of the tests here
|
||||
it 'calls merge' do
|
||||
value = { hello: -> { self } }
|
||||
scope = 'world'
|
||||
|
||||
expect(hash).to receive(:merge).with(value, force: false, scope: scope)
|
||||
.and_call_original
|
||||
new_hash = hash.deep_merge(value, force: false, scope: scope)
|
||||
expect(new_hash).to have_key 'hello'
|
||||
end
|
||||
|
||||
it 'returns a new Hash' do
|
||||
hash['foo'] = ['bar']
|
||||
|
||||
new_hash = hash.deep_merge('beep' => :boop, 'foo' => [123])
|
||||
expect(new_hash).to be_a Rackstash::Fields::Hash
|
||||
|
||||
expect(hash).not_to have_key 'beep'
|
||||
expect(hash['foo']).to contain_exactly 'bar'
|
||||
|
||||
expect(new_hash).not_to equal hash
|
||||
expect(new_hash).to include 'beep', 'foo'
|
||||
expect(new_hash['foo']).to contain_exactly 'bar', 123
|
||||
end
|
||||
end
|
||||
|
||||
describe '#deep_merge!' do
|
||||
let(:forbidden_keys) { ['forbidden'] }
|
||||
|
||||
it 'calls merge!' do
|
||||
value = { hello: -> { self } }
|
||||
scope = 'world'
|
||||
|
||||
expect(hash).to receive(:merge!).with(value, force: false, scope: scope)
|
||||
.and_call_original
|
||||
hash.deep_merge!(value, force: false, scope: scope)
|
||||
expect(hash).to have_key 'hello'
|
||||
end
|
||||
|
||||
it 'returns self' do
|
||||
expect(hash.deep_merge!(foo: :bar)).to equal hash
|
||||
end
|
||||
|
||||
it 'rejects not hash-convertible arguments' do
|
||||
expect { hash.deep_merge!(nil) }.to raise_error TypeError
|
||||
expect { hash.deep_merge!(false) }.to raise_error TypeError
|
||||
expect { hash.deep_merge!(true) }.to raise_error TypeError
|
||||
expect { hash.deep_merge!(123) }.to raise_error TypeError
|
||||
expect { hash.deep_merge!(:foo) }.to raise_error TypeError
|
||||
expect { hash.deep_merge!('foo') }.to raise_error TypeError
|
||||
expect { hash.deep_merge!([]) }.to raise_error TypeError
|
||||
expect { hash.deep_merge!(['foo']) }.to raise_error TypeError
|
||||
|
||||
expect { hash.deep_merge!(-> { 3 }) }.to raise_error TypeError
|
||||
expect { hash.deep_merge!(-> { 'foo' }) }.to raise_error TypeError
|
||||
expect { hash.deep_merge!(-> { ['foo'] }) }.to raise_error TypeError
|
||||
end
|
||||
|
||||
context 'with force: true' do
|
||||
it 'adds fields, overwriting existing ones' do
|
||||
hash['foo'] = 'original'
|
||||
hash.deep_merge!('foo' => 'overwritten', 'bar' => 'some value')
|
||||
|
||||
expect(hash.keys).to contain_exactly 'foo', 'bar'
|
||||
expect(hash['foo']).to eql 'overwritten'
|
||||
expect(hash['bar']).to eql 'some value'
|
||||
end
|
||||
|
||||
it 'merges nested hashes, overwriting existing nested values' do
|
||||
hash['key'] = { 'foo' => 'bar' }
|
||||
|
||||
hash.deep_merge! 'key' => { foo: 'fizz', baz: 'qux' }
|
||||
expect(hash['key'].as_json).to eql 'foo' => 'fizz', 'baz' => 'qux'
|
||||
end
|
||||
|
||||
it 'overwrites nested values unless types match' do
|
||||
hash['key'] = { nested_key: 'value' }
|
||||
|
||||
hash.deep_merge! 'key' => [:foo, 'baz']
|
||||
expect(hash['key'])
|
||||
.to be_a(Rackstash::Fields::Array)
|
||||
.and contain_exactly 'foo', 'baz'
|
||||
|
||||
hash.deep_merge! 'key' => 123
|
||||
expect(hash['key']).to eql 123
|
||||
end
|
||||
end
|
||||
|
||||
context 'with force: false' do
|
||||
it 'adds fields, ignoring existing ones' do
|
||||
hash['foo'] = 'original'
|
||||
hash.deep_merge!({ 'foo' => 'ignored', 'bar' => 'some value' }, force: false)
|
||||
|
||||
expect(hash.keys).to contain_exactly 'foo', 'bar'
|
||||
expect(hash['foo']).to eql 'original'
|
||||
expect(hash['bar']).to eql 'some value'
|
||||
end
|
||||
|
||||
it 'merges nested hashes, ingoring existing nested values' do
|
||||
hash['key'] = { 'foo' => 'bar' }
|
||||
expect(hash['key'].as_json).to eql 'foo' => 'bar'
|
||||
|
||||
hash.deep_merge!({ 'key' => { foo: 'fizz', baz: 'qux' } }, force: false)
|
||||
expect(hash['key'].as_json).to eql 'foo' => 'bar', 'baz' => 'qux'
|
||||
end
|
||||
|
||||
it 'ignores nested values unless types match' do
|
||||
hash['key'] = { nested_key: 'value' }
|
||||
|
||||
hash.deep_merge!({ 'key' => [:foo, 'baz'] }, force: false)
|
||||
expect(hash['key'])
|
||||
.to be_a(Rackstash::Fields::Hash)
|
||||
.and have_key 'nested_key'
|
||||
|
||||
hash.deep_merge!({ 'key' => 123 }, force: false)
|
||||
expect(hash['key'])
|
||||
.to be_a(Rackstash::Fields::Hash)
|
||||
.and have_key 'nested_key'
|
||||
end
|
||||
|
||||
it 'overwrites nil' do
|
||||
hash['key'] = nil
|
||||
expect(hash).to have_key 'key'
|
||||
|
||||
hash.deep_merge!({ 'key' => { nested: 'value' } }, force: false)
|
||||
expect(hash['key']).to be_a Rackstash::Fields::Hash
|
||||
end
|
||||
end
|
||||
|
||||
it 'normalizes string-like array elements to strings' do
|
||||
hash.deep_merge! 'key' => [:foo, [123, 'bar'], [:qux, { fizz: [:buzz, 42] }]]
|
||||
expect(hash['key'].as_json)
|
||||
.to eql ['foo', [123, 'bar'], ['qux', { 'fizz' => ['buzz', 42] }]]
|
||||
|
||||
hash.deep_merge! 'key' => ['foo', :baz, [123, :bar]]
|
||||
expect(hash['key'].as_json)
|
||||
.to eql ['foo', [123, 'bar'], ['qux', { 'fizz' => ['buzz', 42] }], 'baz']
|
||||
end
|
||||
|
||||
it 'raises an error when trying to merge forbidden fields' do
|
||||
expect { hash.deep_merge!(forbidden: 'value') }.to raise_error ArgumentError
|
||||
expect { hash.deep_merge!('forbidden' => 'value') }.to raise_error ArgumentError
|
||||
expect(hash).to_not have_key 'forbidden'
|
||||
end
|
||||
|
||||
it 'allows to merge forbidden fields in nested hashes' do
|
||||
hash.deep_merge!(top: { 'forbidden' => 'value' })
|
||||
expect(hash['top'])
|
||||
.to be_a(Rackstash::Fields::Hash)
|
||||
.and have_key 'forbidden'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#empty?' do
|
||||
it 'returns true of there are any fields' do
|
||||
expect(hash.empty?).to be true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user