Skip to content

Commit 5c47aff

Browse files
authored
Merge pull request #34 from kevindew/array_path
Introduce an array_path option
2 parents 7271736 + 04e4e8b commit 5c47aff

File tree

8 files changed

+163
-35
lines changed

8 files changed

+163
-35
lines changed

README.md

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ diff.should == [['-', 'a[0].x', 2], ['-', 'a[0].z', 4], ['-', 'a[1].y', 22], ['-
7272
patch example:
7373

7474
```ruby
75-
a = {a: 3}
76-
b = {a: {a1: 1, a2: 2}}
75+
a = {'a' => 3}
76+
b = {'a' => {'a1' => 1, 'a2' => 2}}
7777

7878
diff = HashDiff.diff(a, b)
7979
HashDiff.patch!(a, diff).should == b
@@ -82,17 +82,18 @@ HashDiff.patch!(a, diff).should == b
8282
unpatch example:
8383

8484
```ruby
85-
a = [{a: 1, b: 2, c: 3, d: 4, e: 5}, {x: 5, y: 6, z: 3}, 1]
86-
b = [1, {a: 1, b: 2, c: 3, e: 5}]
85+
a = [{'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5}, {'x' => 5, 'y' => 6, 'z' => 3}, 1]
86+
b = [1, {'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5}]
8787

8888
diff = HashDiff.diff(a, b) # diff two array is OK
8989
HashDiff.unpatch!(b, diff).should == a
9090
```
9191

9292
### Options
9393

94-
There are six options available: `:delimiter`, `:similarity`,
95-
`:strict`, `:numeric_tolerance`, `:strip` and `:case_insensitive`.
94+
There are seven options available: `:delimiter`, `:similarity`,
95+
`:strict`, `:numeric_tolerance`, `:strip`, `:case_insensitive`
96+
and `:array_path`.
9697

9798
#### `:delimiter`
9899

@@ -140,7 +141,7 @@ diff.should == [["~", "x", 5, 6]]
140141

141142
#### `:case_insensitive`
142143

143-
The :case_insensitive option makes string comparisions ignore case.
144+
The :case_insensitive option makes string comparisons ignore case.
144145

145146
```ruby
146147
a = {x:5, s:'FooBar'}
@@ -150,6 +151,39 @@ diff = HashDiff.diff(a, b, :comparison => { :numeric_tolerance => 0.1, :case_ins
150151
diff.should == [["~", "x", 5, 6]]
151152
```
152153

154+
#### `:array_path`
155+
156+
The :array_path option represents the path of the diff in an array rather than
157+
a string. This can be used to show differences in between hash key types and
158+
is useful for `patch!` when used on hashes without string keys.
159+
160+
```ruby
161+
a = {x:5}
162+
b = {'x'=>6}
163+
164+
diff = HashDiff.diff(a, b, :array_path => true)
165+
diff.should == [['-', [:x], 5], ['+', ['x'], 6]]
166+
```
167+
168+
For cases where there are arrays in paths their index will be added to the path.
169+
```ruby
170+
a = {x:[0,1]}
171+
b = {x:[0,2]}
172+
173+
diff = HashDiff.diff(a, b, :array_path => true)
174+
diff.should == [["-", [:x, 1], 1], ["+", [:x, 1], 2]]
175+
```
176+
177+
This shouldn't cause problems if you are comparing an array with a hash:
178+
179+
```ruby
180+
a = {x:{0=>1}}
181+
b = {x:[1]}
182+
183+
diff = HashDiff.diff(a, b, :array_path => true)
184+
diff.should == [["~", [:a], [1], {0=>1}]]
185+
```
186+
153187
#### Specifying a custom comparison method
154188

155189
It's possible to specify how the values of a key should be compared.
@@ -186,6 +220,8 @@ diff.should == [["~", "a", "car", "bus"], ["~", "b[1]", "plane", " plan"], ["-",
186220

187221
When a comparison block is given, it'll be given priority over other specified options. If the block returns value other than `true` or `false`, then the two values will be compared with other specified options.
188222

223+
When used in conjunction with the `array_path` option, the path passed in as an argument will be an array. When determining the ordering of an array a key of `"*"` will be used in place of the `key[*]` field. It is possible, if you have hashes with integer or `"*"` keys, to have problems distinguishing between arrays and hashes - although this shouldn't be an issue unless your data is very difficult to predict and/or your custom rules are very specific.
224+
189225
#### Sorting arrays before comparison
190226

191227
An order difference alone between two arrays can create too many diffs to be useful. Consider sorting them prior to diffing.

lib/hashdiff/diff.rb

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module HashDiff
1111
# * :delimiter (String) ['.'] the delimiter used when returning nested key references
1212
# * :numeric_tolerance (Numeric) [0] should be a positive numeric value. Value by which numeric differences must be greater than. By default, numeric values are compared exactly; with the :tolerance option, the difference between numeric values must be greater than the given value.
1313
# * :strip (Boolean) [false] whether or not to call #strip on strings before comparing
14+
# * :array_path (Boolean) [false] whether to return the path references for nested values in an array, can be used for patch compatibility with non string keys.
1415
#
1516
# @yield [path, value1, value2] Optional block is used to compare each value, instead of default #==. If the block returns value other than true of false, then other specified comparison options will be used to do the comparison.
1617
#
@@ -53,6 +54,7 @@ def self.best_diff(obj1, obj2, options = {}, &block)
5354
# * :delimiter (String) ['.'] the delimiter used when returning nested key references
5455
# * :numeric_tolerance (Numeric) [0] should be a positive numeric value. Value by which numeric differences must be greater than. By default, numeric values are compared exactly; with the :tolerance option, the difference between numeric values must be greater than the given value.
5556
# * :strip (Boolean) [false] whether or not to call #strip on strings before comparing
57+
# * :array_path (Boolean) [false] whether to return the path references for nested values in an array, can be used for patch compatibility with non string keys.
5658
#
5759
# @yield [path, value1, value2] Optional block is used to compare each value, instead of default #==. If the block returns value other than true of false, then other specified comparison options will be used to do the comparison.
5860
#
@@ -74,9 +76,12 @@ def self.diff(obj1, obj2, options = {}, &block)
7476
:delimiter => '.',
7577
:strict => true,
7678
:strip => false,
77-
:numeric_tolerance => 0
79+
:numeric_tolerance => 0,
80+
:array_path => false
7881
}.merge!(options)
7982

83+
opts[:prefix] = [] if opts[:array_path] && opts[:prefix] == ''
84+
8085
opts[:comparison] = block if block_given?
8186

8287
# prefer to compare with provided block
@@ -104,51 +109,53 @@ def self.diff(obj1, obj2, options = {}, &block)
104109
changeset = diff_array(obj1, obj2, opts) do |lcs|
105110
# use a's index for similarity
106111
lcs.each do |pair|
107-
result.concat(diff(obj1[pair[0]], obj2[pair[1]], opts.merge(:prefix => "#{opts[:prefix]}[#{pair[0]}]")))
112+
prefix = prefix_append_array_index(opts[:prefix], pair[0], opts)
113+
result.concat(diff(obj1[pair[0]], obj2[pair[1]], opts.merge(:prefix => prefix)))
108114
end
109115
end
110116

111117
changeset.each do |change|
118+
change_key = prefix_append_array_index(opts[:prefix], change[1], opts)
112119
if change[0] == '-'
113-
result << ['-', "#{opts[:prefix]}[#{change[1]}]", change[2]]
120+
result << ['-', change_key, change[2]]
114121
elsif change[0] == '+'
115-
result << ['+', "#{opts[:prefix]}[#{change[1]}]", change[2]]
122+
result << ['+', change_key, change[2]]
116123
end
117124
end
118125
elsif obj1.is_a?(Hash)
119-
if opts[:prefix].empty?
120-
prefix = ""
121-
else
122-
prefix = "#{opts[:prefix]}#{opts[:delimiter]}"
123-
end
124126

125127
deleted_keys = obj1.keys - obj2.keys
126128
common_keys = obj1.keys & obj2.keys
127129
added_keys = obj2.keys - obj1.keys
128130

129131
# add deleted properties
130132
deleted_keys.sort_by{|k,v| k.to_s }.each do |k|
131-
custom_result = custom_compare(opts[:comparison], "#{prefix}#{k}", obj1[k], nil)
133+
change_key = prefix_append_key(opts[:prefix], k, opts)
134+
custom_result = custom_compare(opts[:comparison], change_key, obj1[k], nil)
132135

133136
if custom_result
134137
result.concat(custom_result)
135138
else
136-
result << ['-', "#{prefix}#{k}", obj1[k]]
139+
result << ['-', change_key, obj1[k]]
137140
end
138141
end
139142

140143
# recursive comparison for common keys
141-
common_keys.sort_by{|k,v| k.to_s }.each {|k| result.concat(diff(obj1[k], obj2[k], opts.merge(:prefix => "#{prefix}#{k}"))) }
144+
common_keys.sort_by{|k,v| k.to_s }.each do |k|
145+
prefix = prefix_append_key(opts[:prefix], k, opts)
146+
result.concat(diff(obj1[k], obj2[k], opts.merge(:prefix => prefix)))
147+
end
142148

143149
# added properties
144150
added_keys.sort_by{|k,v| k.to_s }.each do |k|
151+
change_key = prefix_append_key(opts[:prefix], k, opts)
145152
unless obj1.key?(k)
146-
custom_result = custom_compare(opts[:comparison], "#{prefix}#{k}", nil, obj2[k])
153+
custom_result = custom_compare(opts[:comparison], change_key, nil, obj2[k])
147154

148155
if custom_result
149156
result.concat(custom_result)
150157
else
151-
result << ['+', "#{prefix}#{k}", obj2[k]]
158+
result << ['+', change_key, obj2[k]]
152159
end
153160
end
154161
end

lib/hashdiff/lcs.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module HashDiff
66
def self.lcs(a, b, options = {})
77
opts = { :similarity => 0.8 }.merge!(options)
88

9-
opts[:prefix] = "#{opts[:prefix]}[*]"
9+
opts[:prefix] = prefix_append_array_index(opts[:prefix], '*', opts)
1010

1111
return [] if a.size == 0 or b.size == 0
1212

lib/hashdiff/patch.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#
1+
#
22
# This module provides methods to diff two hash, patch and unpatch hash
33
#
44
module HashDiff
@@ -17,19 +17,21 @@ def self.patch!(obj, changes, options = {})
1717
delimiter = options[:delimiter] || '.'
1818

1919
changes.each do |change|
20-
parts = decode_property_path(change[1], delimiter)
20+
parts = change[1]
21+
parts = decode_property_path(parts, delimiter) unless parts.is_a?(Array)
22+
2123
last_part = parts.last
2224

2325
parent_node = node(obj, parts[0, parts.size-1])
2426

2527
if change[0] == '+'
26-
if last_part.is_a?(Integer)
28+
if parent_node.is_a?(Array)
2729
parent_node.insert(last_part, change[2])
2830
else
2931
parent_node[last_part] = change[2]
3032
end
3133
elsif change[0] == '-'
32-
if last_part.is_a?(Integer)
34+
if parent_node.is_a?(Array)
3335
parent_node.delete_at(last_part)
3436
else
3537
parent_node.delete(last_part)
@@ -56,19 +58,21 @@ def self.unpatch!(obj, changes, options = {})
5658
delimiter = options[:delimiter] || '.'
5759

5860
changes.reverse_each do |change|
59-
parts = decode_property_path(change[1], delimiter)
61+
parts = change[1]
62+
parts = decode_property_path(parts, delimiter) unless parts.is_a?(Array)
63+
6064
last_part = parts.last
6165

6266
parent_node = node(obj, parts[0, parts.size-1])
6367

6468
if change[0] == '+'
65-
if last_part.is_a?(Integer)
69+
if parent_node.is_a?(Array)
6670
parent_node.delete_at(last_part)
6771
else
6872
parent_node.delete(last_part)
6973
end
7074
elsif change[0] == '-'
71-
if last_part.is_a?(Integer)
75+
if parent_node.is_a?(Array)
7276
parent_node.insert(last_part, change[2])
7377
else
7478
parent_node[last_part] = change[2]

lib/hashdiff/util.rb

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,17 @@ def self.count_nodes(obj)
5555
#
5656
# e.g. "a.b[3].c" => ['a', 'b', 3, 'c']
5757
def self.decode_property_path(path, delimiter='.')
58-
parts = path.split(delimiter).collect do |part|
58+
path.split(delimiter).inject([]) do |memo, part|
5959
if part =~ /^(.*)\[(\d+)\]$/
6060
if $1.size > 0
61-
[$1, $2.to_i]
61+
memo + [$1, $2.to_i]
6262
else
63-
$2.to_i
63+
memo + [$2.to_i]
6464
end
6565
else
66-
part
66+
memo + [part]
6767
end
6868
end
69-
70-
parts.flatten
7169
end
7270

7371
# @private
@@ -129,4 +127,20 @@ def self.custom_compare(method, key, obj1, obj2)
129127
end
130128
end
131129
end
130+
131+
def self.prefix_append_key(prefix, key, opts)
132+
if opts[:array_path]
133+
prefix + [key]
134+
else
135+
prefix.empty? ? "#{key}" : "#{prefix}#{opts[:delimiter]}#{key}"
136+
end
137+
end
138+
139+
def self.prefix_append_array_index(prefix, array_index, opts)
140+
if opts[:array_path]
141+
prefix + [array_index]
142+
else
143+
"#{prefix}[#{array_index}]"
144+
end
145+
end
132146
end

spec/hashdiff/best_diff_spec.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,13 @@
6262
['+', 'menu.popup.menuitem[1]', {"value" => "Open", "onclick" => "OpenDoc()"}]
6363
]
6464
end
65+
66+
it "should be able to have an array_path specified" do
67+
a = {'x' => [{'a' => 1, 'c' => 3, 'e' => 5}, {'y' => 3}]}
68+
b = {'x' => [{'a' => 1, 'b' => 2, 'e' => 5}] }
69+
70+
diff = HashDiff.best_diff(a, b, :array_path => true)
71+
diff.should == [["-", ["x", 0, "c"], 3], ["+", ["x", 0, "b"], 2], ["-", ["x", 1], {"y"=>3}]]
72+
end
73+
6574
end

spec/hashdiff/diff_spec.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,40 @@
274274
diff.should == [['~', 'b', 'boat', 'truck'], ['~', 'c', 'plane', ' plan']]
275275
end
276276
end
277+
278+
context 'when :array_path is true' do
279+
it 'should return the diff path in an array rather than a string' do
280+
x = { 'a' => 'foo' }
281+
y = { 'a' => 'bar' }
282+
diff = HashDiff.diff(x, y, :array_path => true)
283+
284+
diff.should == [['~', ['a'], 'foo', 'bar']]
285+
end
286+
287+
it 'should show array indexes in paths' do
288+
x = { 'a' => [0, 1, 2] }
289+
y = { 'a' => [0, 1, 2, 3] }
290+
291+
diff = HashDiff.diff(x, y, :array_path => true)
292+
293+
diff.should == [['+', ['a', 3], 3]]
294+
end
295+
296+
it 'should show differences with string and symbol keys' do
297+
x = { 'a' => 'foo' }
298+
y = { :a => 'bar' }
299+
300+
diff = HashDiff.diff(x, y, :array_path => true)
301+
diff.should == [['-', ['a'], 'foo'], ['+', [:a], 'bar']]
302+
end
303+
304+
it 'should support other key types' do
305+
time = Time.now
306+
x = { time => 'foo' }
307+
y = { 0 => 'bar' }
308+
309+
diff = HashDiff.diff(x, y, :array_path => true)
310+
diff.should == [['-', [time], 'foo'], ['+', [0], 'bar']]
311+
end
312+
end
277313
end

spec/hashdiff/patch_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,5 +157,27 @@
157157
HashDiff.unpatch!(b, diff, :delimiter => "\n").should == a
158158
end
159159

160+
it "should be able to patch when the diff is generated with an array_path" do
161+
a = {"a" => 1, "b" => 1}
162+
b = {"a" => 1, "b" => 2}
163+
diff = HashDiff.diff(a, b, :array_path => true)
160164

165+
HashDiff.patch!(a, diff).should == b
166+
167+
a = {"a" => 1, "b" => 1}
168+
b = {"a" => 1, "b" => 2}
169+
HashDiff.unpatch!(b, diff).should == a
170+
end
171+
172+
it "should be able to use non string keys when diff is generated with an array_path" do
173+
a = {"a" => 1, :a => 2, 0 => 3}
174+
b = {"a" => 5, :a => 6, 0 => 7}
175+
diff = HashDiff.diff(a, b, :array_path => true)
176+
177+
HashDiff.patch!(a, diff).should == b
178+
179+
a = {"a" => 1, :a => 2, 0 => 3}
180+
b = {"a" => 5, :a => 6, 0 => 7}
181+
HashDiff.unpatch!(b, diff).should == a
182+
end
161183
end

0 commit comments

Comments
 (0)