diff --git a/lib/rackstash/fields/hash.rb b/lib/rackstash/fields/hash.rb index f487d60..6cdcc28 100644 --- a/lib/rackstash/fields/hash.rb +++ b/lib/rackstash/fields/hash.rb @@ -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) diff --git a/spec/rackstash/fields/hash_spec.rb b/spec/rackstash/fields/hash_spec.rb index bc12189..b469924 100644 --- a/spec/rackstash/fields/hash_spec.rb +++ b/spec/rackstash/fields/hash_spec.rb @@ -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