1
0
mirror of https://github.com/meineerde/rackstash.git synced 2025-12-19 15:01:12 +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:
Holger Just 2017-02-18 22:36:18 +01:00
parent 4a185096d6
commit 22d1cdb646
2 changed files with 308 additions and 0 deletions

View File

@ -70,6 +70,142 @@ module Rackstash
self self
end 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, # @return [Boolean] `true` if the Hash contains no ley-value pairs,
# `false` otherwise. # `false` otherwise.
def empty? def empty?
@ -246,6 +382,23 @@ module Rackstash
return obj.to_hash if obj.respond_to?(:to_hash) return obj.to_hash if obj.respond_to?(:to_hash)
raise TypeError, "no implicit conversion of #{obj.class} into Hash" raise TypeError, "no implicit conversion of #{obj.class} into Hash"
end 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 end
def self.Hash(raw, forbidden_keys: EMPTY_SET) def self.Hash(raw, forbidden_keys: EMPTY_SET)

View File

@ -160,6 +160,161 @@ describe Rackstash::Fields::Hash do
end end
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 describe '#empty?' do
it 'returns true of there are any fields' do it 'returns true of there are any fields' do
expect(hash.empty?).to be true expect(hash.empty?).to be true