Factor out deep_merge into AS::DeepMergeable
The `ActiveSupport::DeepMergeable` module allows a class to provide `deep_merge` and `deep_merge!` methods simply by implementing a `merge!(other, &block)` method. Values will be deep merged only when they are compatible, according to `deep_merge?`. By default, that only includes instances of the same class or its subclasses. A class may override `deep_merge?` to further restrict or expand the domain of deep mergeable values. This does introduce a small change in behavior. Previously, `Hash#deep_merge` would only deep merge `Hash` instances. Now, `deep_merge` will deep merge any `DeepMergeable` instances that are compatible with each other.
This commit is contained in:
parent
be400c3ca2
commit
43b980368a
@ -1,6 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "active_support/deep_mergeable"
|
||||
|
||||
class Hash
|
||||
include ActiveSupport::DeepMergeable
|
||||
|
||||
##
|
||||
# :method: deep_merge
|
||||
# :call-seq: deep_merge(other_hash, &block)
|
||||
#
|
||||
# Returns a new hash with +self+ and +other_hash+ merged recursively.
|
||||
#
|
||||
# h1 = { a: true, b: { c: [1, 2, 3] } }
|
||||
@ -15,20 +23,20 @@ class Hash
|
||||
# h2 = { b: 250, c: { c1: 200 } }
|
||||
# h1.deep_merge(h2) { |key, this_val, other_val| this_val + other_val }
|
||||
# # => { a: 100, b: 450, c: { c1: 300 } }
|
||||
def deep_merge(other_hash, &block)
|
||||
dup.deep_merge!(other_hash, &block)
|
||||
end
|
||||
#
|
||||
#--
|
||||
# Implemented by ActiveSupport::DeepMergeable#deep_merge.
|
||||
|
||||
# Same as +deep_merge+, but modifies +self+.
|
||||
def deep_merge!(other_hash, &block)
|
||||
merge!(other_hash) do |key, this_val, other_val|
|
||||
if this_val.is_a?(Hash) && other_val.is_a?(Hash)
|
||||
this_val.deep_merge(other_val, &block)
|
||||
elsif block_given?
|
||||
block.call(key, this_val, other_val)
|
||||
else
|
||||
other_val
|
||||
end
|
||||
end
|
||||
##
|
||||
# :method: deep_merge!
|
||||
# :call-seq: deep_merge!(other_hash, &block)
|
||||
#
|
||||
# Same as #deep_merge, but modifies +self+.
|
||||
#
|
||||
#--
|
||||
# Implemented by ActiveSupport::DeepMergeable#deep_merge!.
|
||||
|
||||
def deep_merge?(other) # :nodoc:
|
||||
other.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
|
53
activesupport/lib/active_support/deep_mergeable.rb
Normal file
53
activesupport/lib/active_support/deep_mergeable.rb
Normal file
@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ActiveSupport
|
||||
# Provides +deep_merge+ and +deep_merge!+ methods. Expects the including class
|
||||
# to provide a <tt>merge!(other, &block)</tt> method.
|
||||
module DeepMergeable # :nodoc:
|
||||
# Returns a new instance with the values from +other+ merged recursively.
|
||||
#
|
||||
# class Hash
|
||||
# include ActiveSupport::DeepMergeable
|
||||
# end
|
||||
#
|
||||
# hash_1 = { a: true, b: { c: [1, 2, 3] } }
|
||||
# hash_2 = { a: false, b: { x: [3, 4, 5] } }
|
||||
#
|
||||
# hash_1.deep_merge(hash_2)
|
||||
# # => { a: false, b: { c: [1, 2, 3], x: [3, 4, 5] } }
|
||||
#
|
||||
# A block can be provided to merge non-<tt>DeepMergeable</tt> values:
|
||||
#
|
||||
# hash_1 = { a: 100, b: 200, c: { c1: 100 } }
|
||||
# hash_2 = { b: 250, c: { c1: 200 } }
|
||||
#
|
||||
# hash_1.deep_merge(hash_2) do |key, this_val, other_val|
|
||||
# this_val + other_val
|
||||
# end
|
||||
# # => { a: 100, b: 450, c: { c1: 300 } }
|
||||
#
|
||||
def deep_merge(other, &block)
|
||||
dup.deep_merge!(other, &block)
|
||||
end
|
||||
|
||||
# Same as #deep_merge, but modifies +self+.
|
||||
def deep_merge!(other, &block)
|
||||
merge!(other) do |key, this_val, other_val|
|
||||
if this_val.is_a?(DeepMergeable) && this_val.deep_merge?(other_val)
|
||||
this_val.deep_merge(other_val, &block)
|
||||
elsif block_given?
|
||||
block.call(key, this_val, other_val)
|
||||
else
|
||||
other_val
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if +other+ can be deep merged into +self+. Classes may
|
||||
# override this method to restrict or expand the domain of deep mergeable
|
||||
# values. Defaults to checking that +other+ is of type +self.class+.
|
||||
def deep_merge?(other)
|
||||
other.is_a?(self.class)
|
||||
end
|
||||
end
|
||||
end
|
94
activesupport/test/deep_mergeable_test.rb
Normal file
94
activesupport/test/deep_mergeable_test.rb
Normal file
@ -0,0 +1,94 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "abstract_unit"
|
||||
|
||||
class DeepMergeableTest < ActiveSupport::TestCase
|
||||
Wrapper = Struct.new(:underlying) do
|
||||
include ActiveSupport::DeepMergeable
|
||||
|
||||
def self.[](value)
|
||||
if value.is_a?(Hash)
|
||||
self.new(value.transform_values { |value| self[value] })
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
delegate :[], to: :underlying
|
||||
|
||||
def merge!(other, &block)
|
||||
self.underlying = underlying.merge(other.underlying, &block)
|
||||
self
|
||||
end
|
||||
end
|
||||
|
||||
SubWrapper = Class.new(Wrapper)
|
||||
|
||||
OtherWrapper = Wrapper.dup
|
||||
|
||||
OmniWrapper = Class.new(Wrapper) do
|
||||
def deep_merge?(other)
|
||||
super || other.is_a?(OtherWrapper)
|
||||
end
|
||||
end
|
||||
|
||||
setup do
|
||||
@hash_1 = { a: 1, b: 1, c: { d1: 1, d2: 1, d3: { e1: 1, e3: 1 } } }
|
||||
@hash_2 = { a: 2, c: { d2: 2, d3: { e2: 2, e3: 2 } } }
|
||||
@merged = { a: 2, b: 1, c: { d1: 1, d2: 2, d3: { e1: 1, e2: 2, e3: 2 } } }
|
||||
@summed = { a: 3, b: 1, c: { d1: 1, d2: 3, d3: { e1: 1, e2: 2, e3: 3 } } }
|
||||
|
||||
@nested_value_key = :c
|
||||
@sum_values = -> (key, value_1, value_2) { value_1 + value_2 }
|
||||
end
|
||||
|
||||
test "deep_merge works" do
|
||||
assert_equal Wrapper[@merged], Wrapper[@hash_1].deep_merge(Wrapper[@hash_2])
|
||||
end
|
||||
|
||||
test "deep_merge! works" do
|
||||
assert_equal Wrapper[@merged], Wrapper[@hash_1].deep_merge!(Wrapper[@hash_2])
|
||||
end
|
||||
|
||||
test "deep_merge supports a merge block" do
|
||||
assert_equal Wrapper[@summed], Wrapper[@hash_1].deep_merge(Wrapper[@hash_2], &@sum_values)
|
||||
end
|
||||
|
||||
test "deep_merge! supports a merge block" do
|
||||
assert_equal Wrapper[@summed], Wrapper[@hash_1].deep_merge!(Wrapper[@hash_2], &@sum_values)
|
||||
end
|
||||
|
||||
test "deep_merge does not mutate the instance" do
|
||||
instance = Wrapper[@hash_1.dup]
|
||||
instance.deep_merge(Wrapper[@hash_2])
|
||||
assert_equal Wrapper[@hash_1], instance
|
||||
end
|
||||
|
||||
test "deep_merge! mutates the instance" do
|
||||
instance = Wrapper[@hash_1]
|
||||
instance.deep_merge!(Wrapper[@hash_2])
|
||||
assert_equal Wrapper[@merged], instance
|
||||
end
|
||||
|
||||
test "deep_merge! does not mutate the underlying values" do
|
||||
instance = Wrapper[@hash_1.dup]
|
||||
underlying = instance.underlying
|
||||
instance.deep_merge!(Wrapper[@hash_2])
|
||||
assert_equal Wrapper[@hash_1].underlying, underlying
|
||||
end
|
||||
|
||||
test "deep_merge deep merges subclass values by default" do
|
||||
nested_value = Wrapper[@hash_1].deep_merge(SubWrapper[@hash_2])[@nested_value_key]
|
||||
assert_equal Wrapper[@merged][@nested_value_key], nested_value
|
||||
end
|
||||
|
||||
test "deep_merge does not deep merge non-subclass values by default" do
|
||||
nested_value = Wrapper[@hash_1].deep_merge(OtherWrapper[@hash_2])[@nested_value_key]
|
||||
assert_equal OtherWrapper[@hash_2][@nested_value_key], nested_value
|
||||
end
|
||||
|
||||
test "deep_merge? can be overridden to allow deep merging of non-subclass values" do
|
||||
nested_value = OmniWrapper[@hash_1].deep_merge(OtherWrapper[@hash_2])[@nested_value_key]
|
||||
assert_equal OmniWrapper[@merged][@nested_value_key], nested_value
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user