hashdiff-1.0.0/0000755000004100000410000000000013522344705013321 5ustar www-datawww-datahashdiff-1.0.0/.travis.yml0000644000004100000410000000016613522344705015435 0ustar www-datawww-datasudo: false language: ruby cache: bundler rvm: - 2.0.0 - 2.1.10 - 2.2.8 - 2.3.4 - 2.4.2 - 2.5.3 - 2.6.0 hashdiff-1.0.0/.rspec0000644000004100000410000000001013522344705014425 0ustar www-datawww-data--color hashdiff-1.0.0/README.md0000644000004100000410000002046513522344705014607 0ustar www-datawww-data# Hashdiff [![Build Status](https://secure.travis-ci.org/liufengyun/hashdiff.svg)](http://travis-ci.org/liufengyun/hashdiff) [![Gem Version](https://badge.fury.io/rb/hashdiff.svg)](http://badge.fury.io/rb/hashdiff) Hashdiff is a ruby library to compute the smallest difference between two hashes. It also supports comparing two arrays. Hashdiff does not monkey-patch any existing class. All features are contained inside the `Hashdiff` module. **Docs**: [Documentation](http://rubydoc.info/gems/hashdiff) __WARNING__: Don't use the library for comparing large arrays, say ~10K (see #49). ## Why Hashdiff? Given two Hashes A and B, sometimes you face the question: what's the smallest modification that can be made to change A into B? An algorithm that responds to this question has to do following: * Generate a list of additions, deletions and changes, so that `A + ChangeSet = B` and `B - ChangeSet = A`. * Compute recursively -- Arrays and Hashes may be nested arbitrarily in A or B. * Compute the smallest change -- it should recognize similar child Hashes or child Arrays between A and B. Hashdiff answers the question above using an opinionated approach: * Hash can be represented as a list of (dot-syntax-path, value) pairs. For example, `{a:[{c:2}]}` can be represented as `["a[0].c", 2]`. * The change set can be represented using the dot-syntax representation. For example, `[['-', 'b.x', 3], ['~', 'b.z', 45, 30], ['+', 'b.y', 3]]`. * It compares Arrays using the [LCS(longest common subsequence)](http://en.wikipedia.org/wiki/Longest_common_subsequence_problem) algorithm. * It recognizes similar Hashes in an Array using a similarity value (0 < similarity <= 1). ## Usage To use the gem, add the following to your Gemfile: ```ruby gem 'hashdiff' ``` ## Quick Start ### Diff Two simple hashes: ```ruby a = {a:3, b:2} b = {} diff = Hashdiff.diff(a, b) diff.should == [['-', 'a', 3], ['-', 'b', 2]] ``` More complex hashes: ```ruby a = {a:{x:2, y:3, z:4}, b:{x:3, z:45}} b = {a:{y:3}, b:{y:3, z:30}} diff = Hashdiff.diff(a, b) diff.should == [['-', 'a.x', 2], ['-', 'a.z', 4], ['-', 'b.x', 3], ['~', 'b.z', 45, 30], ['+', 'b.y', 3]] ``` Arrays in hashes: ```ruby a = {a:[{x:2, y:3, z:4}, {x:11, y:22, z:33}], b:{x:3, z:45}} b = {a:[{y:3}, {x:11, z:33}], b:{y:22}} diff = Hashdiff.best_diff(a, b) diff.should == [['-', 'a[0].x', 2], ['-', 'a[0].z', 4], ['-', 'a[1].y', 22], ['-', 'b.x', 3], ['-', 'b.z', 45], ['+', 'b.y', 22]] ``` ### Patch patch example: ```ruby a = {'a' => 3} b = {'a' => {'a1' => 1, 'a2' => 2}} diff = Hashdiff.diff(a, b) Hashdiff.patch!(a, diff).should == b ``` unpatch example: ```ruby a = [{'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5}, {'x' => 5, 'y' => 6, 'z' => 3}, 1] b = [1, {'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5}] diff = Hashdiff.diff(a, b) # diff two array is OK Hashdiff.unpatch!(b, diff).should == a ``` ### Options There are eight options available: `:delimiter`, `:similarity`, `:strict`, `:numeric_tolerance`, `:strip`, `:case_insensitive`, `:array_path` and `:use_lcs` #### `:delimiter` You can specify `:delimiter` to be something other than the default dot. For example: ```ruby a = {a:{x:2, y:3, z:4}, b:{x:3, z:45}} b = {a:{y:3}, b:{y:3, z:30}} diff = Hashdiff.diff(a, b, :delimiter => '\t') diff.should == [['-', 'a\tx', 2], ['-', 'a\tz', 4], ['-', 'b\tx', 3], ['~', 'b\tz', 45, 30], ['+', 'b\ty', 3]] ``` #### `:similarity` In cases where you have similar hash objects in arrays, you can pass a custom value for `:similarity` instead of the default `0.8`. This is interpreted as a ratio of similarity (default is 80% similar, whereas `:similarity => 0.5` would look for at least a 50% similarity). #### `:strict` The `:strict` option, which defaults to `true`, specifies whether numeric types are compared on type as well as value. By default, an Integer will never be equal to a Float (e.g. 4 != 4.0). Setting `:strict` to false makes the comparison looser (e.g. 4 == 4.0). #### `:numeric_tolerance` The :numeric_tolerance option allows for a small numeric tolerance. ```ruby a = {x:5, y:3.75, z:7} b = {x:6, y:3.76, z:7} diff = Hashdiff.diff(a, b, :numeric_tolerance => 0.1) diff.should == [["~", "x", 5, 6]] ``` #### `:strip` The :strip option strips all strings before comparing. ```ruby a = {x:5, s:'foo '} b = {x:6, s:'foo'} diff = Hashdiff.diff(a, b, :comparison => { :numeric_tolerance => 0.1, :strip => true }) diff.should == [["~", "x", 5, 6]] ``` #### `:case_insensitive` The :case_insensitive option makes string comparisons ignore case. ```ruby a = {x:5, s:'FooBar'} b = {x:6, s:'foobar'} diff = Hashdiff.diff(a, b, :comparison => { :numeric_tolerance => 0.1, :case_insensitive => true }) diff.should == [["~", "x", 5, 6]] ``` #### `:array_path` The :array_path option represents the path of the diff in an array rather than a string. This can be used to show differences in between hash key types and is useful for `patch!` when used on hashes without string keys. ```ruby a = {x:5} b = {'x'=>6} diff = Hashdiff.diff(a, b, :array_path => true) diff.should == [['-', [:x], 5], ['+', ['x'], 6]] ``` For cases where there are arrays in paths their index will be added to the path. ```ruby a = {x:[0,1]} b = {x:[0,2]} diff = Hashdiff.diff(a, b, :array_path => true) diff.should == [["-", [:x, 1], 1], ["+", [:x, 1], 2]] ``` This shouldn't cause problems if you are comparing an array with a hash: ```ruby a = {x:{0=>1}} b = {x:[1]} diff = Hashdiff.diff(a, b, :array_path => true) diff.should == [["~", [:x], {0=>1}, [1]]] ``` #### `:use_lcs` The :use_lcs option is used to specify whether a [Longest common subsequence](https://en.wikipedia.org/wiki/Longest_common_subsequence_problem) (LCS) algorithm is used to determine differences in arrays. This defaults to `true` but can be changed to `false` for significantly faster array comparisons (O(n) complexity rather than O(n2) for LCS). When :use_lcs is false the results of array comparisons have a tendency to show changes at indexes rather than additions and subtractions when :use_lcs is true. Note, currently the :similarity option has no effect when :use_lcs is false. ```ruby a = {x: [0, 1, 2]} b = {x: [0, 2, 2, 3]} diff = Hashdiff.diff(a, b, :use_lcs => false) diff.should == [["~", "x[1]", 1, 2], ["+", "x[3]", 3]] ``` #### Specifying a custom comparison method It's possible to specify how the values of a key should be compared. ```ruby a = {a:'car', b:'boat', c:'plane'} b = {a:'bus', b:'truck', c:' plan'} diff = Hashdiff.diff(a, b) do |path, obj1, obj2| case path when /a|b|c/ obj1.length == obj2.length end end diff.should == [['~', 'b', 'boat', 'truck']] ``` The yielded params of the comparison block is `|path, obj1, obj2|`, in which path is the key (or delimited compound key) to the value being compared. When comparing elements in array, the path is with the format `array[*]`. For example: ```ruby a = {a:'car', b:['boat', 'plane'] } b = {a:'bus', b:['truck', ' plan'] } diff = Hashdiff.diff(a, b) do |path, obj1, obj2| case path when 'b[*]' obj1.length == obj2.length end end diff.should == [["~", "a", "car", "bus"], ["~", "b[1]", "plane", " plan"], ["-", "b[0]", "boat"], ["+", "b[0]", "truck"]] ``` 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. 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. #### Sorting arrays before comparison An order difference alone between two arrays can create too many diffs to be useful. Consider sorting them prior to diffing. ```ruby a = {a:'car', b:['boat', 'plane'] } b = {a:'car', b:['plane', 'boat'] } Hashdiff.diff(a, b) => [["+", "b[0]", "plane"], ["-", "b[2]", "plane"]] b[:b].sort! Hashdiff.diff(a, b) => [] ``` ## Maintainers - Krzysztof Rybka ([@krzysiek1507](https://github.com/krzysiek1507)) - Fengyun Liu ([@liufengyun](https://github.com/liufengyun)) ## License Hashdiff is distributed under the MIT-LICENSE. hashdiff-1.0.0/hashdiff.gemspec0000644000004100000410000000262413522344705016446 0ustar www-datawww-data# frozen_string_literal: true $LOAD_PATH << File.expand_path('lib', __dir__) require 'hashdiff/version' Gem::Specification.new do |s| s.name = 'hashdiff' s.version = Hashdiff::VERSION s.license = 'MIT' s.summary = ' Hashdiff is a diff lib to compute the smallest difference between two hashes. ' s.description = ' Hashdiff is a diff lib to compute the smallest difference between two hashes. ' s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- Appraisals {spec}/*`.split("\n") s.require_paths = ['lib'] s.required_ruby_version = Gem::Requirement.new('>= 2.0.0') s.authors = ['Liu Fengyun'] s.email = ['liufengyunchina@gmail.com'] s.homepage = 'https://github.com/liufengyun/hashdiff' s.add_development_dependency('bluecloth') s.add_development_dependency('rspec', '~> 2.0') s.add_development_dependency('rubocop') s.add_development_dependency('rubocop-rspec') s.add_development_dependency('yard') if s.respond_to?(:metadata) s.metadata = { 'bug_tracker_uri' => 'https://github.com/liufengyun/hashdiff/issues', 'changelog_uri' => 'https://github.com/liufengyun/hashdiff/blob/master/changelog.md', 'documentation_uri' => 'https://www.rubydoc.info/gems/hashdiff', 'homepage_uri' => 'https://github.com/liufengyun/hashdiff', 'source_code_uri' => 'https://github.com/liufengyun/hashdiff' } end end hashdiff-1.0.0/spec/0000755000004100000410000000000013522344705014253 5ustar www-datawww-datahashdiff-1.0.0/spec/hashdiff/0000755000004100000410000000000013522344705016027 5ustar www-datawww-datahashdiff-1.0.0/spec/hashdiff/diff_spec.rb0000644000004100000410000002732413522344705020306 0ustar www-datawww-data# frozen_string_literal: true require 'spec_helper' describe Hashdiff do it 'is able to diff two empty hashes' do diff = described_class.diff({}, {}) diff.should == [] end it 'is able to diff an hash with an empty hash' do a = { 'a' => 3, 'b' => 2 } b = {} diff = described_class.diff(a, b) expect(diff).to eq([['-', 'a', 3], ['-', 'b', 2]]) diff = described_class.diff(b, a) diff.should == [['+', 'a', 3], ['+', 'b', 2]] end it 'is able to diff two equal hashes' do diff = described_class.diff({ 'a' => 2, 'b' => 2 }, 'a' => 2, 'b' => 2) diff.should == [] end it 'is able to diff two equal hashes with mixed key types' do a = { 'a' => 1, :b => 1 } diff = described_class.diff(a, a) diff.should == [] end it 'is able to diff if mixed key types are removed' do a = { 'a' => 1, :b => 1 } b = {} diff = described_class.diff(a, b) diff.should == [['-', 'a', 1], ['-', 'b', 1]] end it 'is able to diff if mixed key types are added' do a = { 'a' => 1, :b => 1 } b = {} diff = described_class.diff(b, a) diff.should == [['+', 'a', 1], ['+', 'b', 1]] end it 'is able to diff two hashes with equivalent numerics, when strict is false' do diff = described_class.diff({ 'a' => 2.0, 'b' => 2 }, { 'a' => 2, 'b' => 2.0 }, strict: false) diff.should == [] end it 'is able to diff changes in hash value' do diff = described_class.diff({ 'a' => 2, 'b' => 3, 'c' => ' hello' }, 'a' => 2, 'b' => 4, 'c' => 'hello') diff.should == [['~', 'b', 3, 4], ['~', 'c', ' hello', 'hello']] end it 'is able to diff changes in hash value which is array' do diff = described_class.diff({ 'a' => 2, 'b' => [1, 2, 3] }, 'a' => 2, 'b' => [1, 3, 4]) diff.should == [['-', 'b[1]', 2], ['+', 'b[2]', 4]] end it 'is able to diff changes in hash value which is hash' do diff = described_class.diff({ 'a' => { 'x' => 2, 'y' => 3, 'z' => 4 }, 'b' => { 'x' => 3, 'z' => 45 } }, 'a' => { 'y' => 3 }, 'b' => { 'y' => 3, 'z' => 30 }) diff.should == [['-', 'a.x', 2], ['-', 'a.z', 4], ['-', 'b.x', 3], ['~', 'b.z', 45, 30], ['+', 'b.y', 3]] end it 'is able to best diff similar objects in array' do diff = described_class.best_diff({ 'a' => [{ 'x' => 2, 'y' => 3, 'z' => 4 }, { 'x' => 11, 'y' => 22, 'z' => 33 }], 'b' => { 'x' => 3, 'z' => 45 } }, 'a' => [{ 'y' => 3 }, { 'x' => 11, 'z' => 33 }], 'b' => { 'y' => 22 }) diff.should == [['-', 'a[0].x', 2], ['-', 'a[0].z', 4], ['-', 'a[1].y', 22], ['-', 'b.x', 3], ['-', 'b.z', 45], ['+', 'b.y', 22]] end it 'is able to diff addition of key value pair' do a = { 'a' => 3, 'c' => 11, 'd' => 45, 'e' => 100, 'f' => 200 } b = { 'a' => 3, 'c' => 11, 'd' => 45, 'e' => 100, 'f' => 200, 'g' => 300 } diff = described_class.diff(a, b) expect(diff).to eq([['+', 'g', 300]]) diff = described_class.diff(b, a) diff.should == [['-', 'g', 300]] end it 'is able to diff value type changes' do a = { 'a' => 3 } b = { 'a' => { 'a1' => 1, 'a2' => 2 } } diff = described_class.diff(a, b) expect(diff).to eq([['~', 'a', 3, { 'a1' => 1, 'a2' => 2 }]]) diff = described_class.diff(b, a) diff.should == [['~', 'a', { 'a1' => 1, 'a2' => 2 }, 3]] end it 'is able to diff value changes: array <=> []' do a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1, 'b' => [] } diff = described_class.diff(a, b) diff.should == [['-', 'b[1]', 2], ['-', 'b[0]', 1]] end it 'is able to diff value changes: array <=> nil' do a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1, 'b' => nil } diff = described_class.diff(a, b) diff.should == [['~', 'b', [1, 2], nil]] end it 'is able to diff value chagnes: remove array completely' do a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1 } diff = described_class.diff(a, b) diff.should == [['-', 'b', [1, 2]]] end it 'is able to diff value changes: remove whole hash' do a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1 } diff = described_class.diff(a, b) diff.should == [['-', 'b', { 'b1' => 1, 'b2' => 2 }]] end it 'is able to diff value changes: hash <=> {}' do a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1, 'b' => {} } diff = described_class.diff(a, b) diff.should == [['-', 'b.b1', 1], ['-', 'b.b2', 2]] end it 'is able to diff value changes: hash <=> nil' do a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1, 'b' => nil } diff = described_class.diff(a, b) diff.should == [['~', 'b', { 'b1' => 1, 'b2' => 2 }, nil]] end it 'is able to diff similar objects in array' do a = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 }, 3] b = [1, { 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 }] diff = described_class.diff(a, b) diff.should == [['-', '[0].d', 4], ['+', '[0]', 1], ['-', '[2]', 3]] end it 'is able to diff similar & equal objects in array' do a = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 }, { 'x' => 5, 'y' => 6, 'z' => 3 }, 3] b = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 }, 3] diff = described_class.diff(a, b) diff.should == [['-', '[0].d', 4], ['-', '[1]', { 'x' => 5, 'y' => 6, 'z' => 3 }]] end it 'uses custom delimiter when provided' do a = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 }, { 'x' => 5, 'y' => 6, 'z' => 3 }, 3] b = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 }, 3] diff = described_class.diff(a, b, similarity: 0.8, delimiter: "\t") diff.should == [['-', "[0]\td", 4], ['-', '[1]', { 'x' => 5, 'y' => 6, 'z' => 3 }]] end context 'when :numeric_tolerance requested' do it 'is able to diff changes in hash value' do a = { 'a' => 0.558, 'b' => 0.0, 'c' => 0.65, 'd' => 'fin' } b = { 'a' => 0.557, 'b' => 'hats', 'c' => 0.67, 'd' => 'fin' } diff = described_class.diff(a, b, numeric_tolerance: 0.01) expect(diff).to eq([['~', 'b', 0.0, 'hats'], ['~', 'c', 0.65, 0.67]]) diff = described_class.diff(b, a, numeric_tolerance: 0.01) diff.should == [['~', 'b', 'hats', 0.0], ['~', 'c', 0.67, 0.65]] end it 'is able to diff changes in nested values' do a = { 'a' => { 'x' => 0.4, 'y' => 0.338 }, 'b' => [13, 68.03] } b = { 'a' => { 'x' => 0.6, 'y' => 0.341 }, 'b' => [14, 68.025] } diff = described_class.diff(a, b, numeric_tolerance: 0.01) expect(diff).to eq([['~', 'a.x', 0.4, 0.6], ['-', 'b[0]', 13], ['+', 'b[0]', 14]]) diff = described_class.diff(b, a, numeric_tolerance: 0.01) diff.should == [['~', 'a.x', 0.6, 0.4], ['-', 'b[0]', 14], ['+', 'b[0]', 13]] end end context 'when :strip requested' do it 'strips strings before comparing' do a = { 'a' => ' foo', 'b' => 'fizz buzz' } b = { 'a' => 'foo', 'b' => 'fizzbuzz' } diff = described_class.diff(a, b, strip: true) diff.should == [['~', 'b', 'fizz buzz', 'fizzbuzz']] end it 'strips nested strings before comparing' do a = { 'a' => { 'x' => ' foo' }, 'b' => ['fizz buzz', 'nerf'] } b = { 'a' => { 'x' => 'foo' }, 'b' => %w[fizzbuzz nerf] } diff = described_class.diff(a, b, strip: true) diff.should == [['-', 'b[0]', 'fizz buzz'], ['+', 'b[0]', 'fizzbuzz']] end end context 'when :case_insensitive requested' do it 'strips strings before comparing' do a = { 'a' => 'Foo', 'b' => 'fizz buzz' } b = { 'a' => 'foo', 'b' => 'fizzBuzz' } diff = described_class.diff(a, b, case_insensitive: true) diff.should == [['~', 'b', 'fizz buzz', 'fizzBuzz']] end it 'ignores case on nested strings before comparing' do a = { 'a' => { 'x' => 'Foo' }, 'b' => ['fizz buzz', 'nerf'] } b = { 'a' => { 'x' => 'foo' }, 'b' => %w[fizzbuzz nerf] } diff = described_class.diff(a, b, case_insensitive: true) diff.should == [['-', 'b[0]', 'fizz buzz'], ['+', 'b[0]', 'fizzbuzz']] end end context 'when both :strip and :numeric_tolerance requested' do it 'applies filters to proper object types' do a = { 'a' => ' foo', 'b' => 35, 'c' => 'bar', 'd' => 'baz' } b = { 'a' => 'foo', 'b' => 35.005, 'c' => 'bar', 'd' => 18.5 } diff = described_class.diff(a, b, strict: false, numeric_tolerance: 0.01, strip: true) diff.should == [['~', 'd', 'baz', 18.5]] end end context 'when both :strip and :case_insensitive requested' do it 'applies both filters to strings' do a = { 'a' => ' Foo', 'b' => 'fizz buzz' } b = { 'a' => 'foo', 'b' => 'fizzBuzz' } diff = described_class.diff(a, b, case_insensitive: true, strip: true) diff.should == [['~', 'b', 'fizz buzz', 'fizzBuzz']] end end context 'with custom comparison' do let(:a) { { 'a' => 'car', 'b' => 'boat', 'c' => 'plane' } } let(:b) { { 'a' => 'bus', 'b' => 'truck', 'c' => ' plan' } } it 'compares using proc specified in block' do diff = described_class.diff(a, b) do |prefix, obj1, obj2| case prefix when /a|b|c/ obj1.length == obj2.length end end diff.should == [['~', 'b', 'boat', 'truck']] end it 'yields added keys' do x = { 'a' => 'car', 'b' => 'boat' } y = { 'a' => 'car' } diff = described_class.diff(x, y) do |prefix, _obj1, _obj2| case prefix when /b/ true end end diff.should == [] end it 'compares with both proc and :strip when both provided' do diff = described_class.diff(a, b, strip: true) do |prefix, obj1, obj2| case prefix when 'a' obj1.length == obj2.length end end diff.should == [['~', 'b', 'boat', 'truck'], ['~', 'c', 'plane', ' plan']] end it 'compares nested arrays using proc specified in block' do a = { a: 'car', b: %w[boat plane] } b = { a: 'bus', b: ['truck', ' plan'] } diff = described_class.diff(a, b) do |path, obj1, obj2| case path when 'b[*]' obj1.length == obj2.length end end expect(diff).to eq [['~', 'a', 'car', 'bus'], ['~', 'b[1]', 'plane', ' plan'], ['-', 'b[0]', 'boat'], ['+', 'b[0]', 'truck']] end end context 'when :array_path is true' do it 'returns the diff path in an array rather than a string' do x = { 'a' => 'foo' } y = { 'a' => 'bar' } diff = described_class.diff(x, y, array_path: true) diff.should == [['~', ['a'], 'foo', 'bar']] end it 'shows array indexes in paths' do x = { 'a' => [0, 1, 2] } y = { 'a' => [0, 1, 2, 3] } diff = described_class.diff(x, y, array_path: true) diff.should == [['+', ['a', 3], 3]] end it 'shows differences with string and symbol keys' do x = { 'a' => 'foo' } y = { a: 'bar' } diff = described_class.diff(x, y, array_path: true) diff.should == [['-', ['a'], 'foo'], ['+', [:a], 'bar']] end it 'supports other key types' do time = Time.now x = { time => 'foo' } y = { 0 => 'bar' } diff = described_class.diff(x, y, array_path: true) diff.should == [['-', [time], 'foo'], ['+', [0], 'bar']] end end context 'when :use_lcs is false' do it 'shows items in an array as changed' do x = %i[a b] y = %i[c d] diff = described_class.diff(x, y, use_lcs: false) diff.should == [['~', '[0]', :a, :c], ['~', '[1]', :b, :d]] end it 'shows additions to arrays' do x = { a: [0] } y = { a: [0, 1] } diff = described_class.diff(x, y, use_lcs: false) diff.should == [['+', 'a[1]', 1]] end it 'shows changes to nested arrays' do x = { a: [[0, 1]] } y = { a: [[1, 2]] } diff = described_class.diff(x, y, use_lcs: false) diff.should == [['~', 'a[0][0]', 0, 1], ['~', 'a[0][1]', 1, 2]] end end end hashdiff-1.0.0/spec/hashdiff/linear_compare_array_spec.rb0000644000004100000410000000317213522344705023547 0ustar www-datawww-data# frozen_string_literal: true require 'spec_helper' describe Hashdiff::LinearCompareArray do it 'finds no differences between two empty arrays' do difference = described_class.call([], []) difference.should == [] end it 'finds added items when the old array is empty' do difference = described_class.call([], %i[a b]) difference.should == [['+', '[0]', :a], ['+', '[1]', :b]] end it 'finds removed items when the new array is empty' do difference = described_class.call(%i[a b], []) difference.should == [['-', '[1]', :b], ['-', '[0]', :a]] end it 'finds no differences between identical arrays' do difference = described_class.call(%i[a b], %i[a b]) difference.should == [] end it 'finds added items in an array' do difference = described_class.call(%i[a d], %i[a b c d]) difference.should == [['+', '[1]', :b], ['+', '[2]', :c]] end it 'finds removed items in an array' do difference = described_class.call(%i[a b c d e f], %i[a d f]) difference.should == [['-', '[4]', :e], ['-', '[2]', :c], ['-', '[1]', :b]] end it 'shows additions and deletions as changed items' do difference = described_class.call(%i[a b c], %i[c b a]) difference.should == [['~', '[0]', :a, :c], ['~', '[2]', :c, :a]] end it 'shows changed items in a hash' do difference = described_class.call([{ a: :b }], [{ a: :c }]) difference.should == [['~', '[0].a', :b, :c]] end it 'shows changed items and added items' do difference = described_class.call([{ a: 1, b: 2 }], [{ a: 2, b: 2 }, :item]) difference.should == [['~', '[0].a', 1, 2], ['+', '[1]', :item]] end end hashdiff-1.0.0/spec/hashdiff/util_spec.rb0000644000004100000410000000663713522344705020357 0ustar www-datawww-data# frozen_string_literal: true require 'spec_helper' describe Hashdiff do it 'is able to decode property path' do decoded = described_class.send(:decode_property_path, 'a.b[0].c.city[5]') decoded.should == ['a', 'b', 0, 'c', 'city', 5] end it 'is able to decode property path with custom delimiter' do decoded = described_class.send(:decode_property_path, "a\tb[0]\tc\tcity[5]", "\t") decoded.should == ['a', 'b', 0, 'c', 'city', 5] end it 'is able to tell similiar hash' do a = { 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 } b = { 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 } described_class.similar?(a, b).should be true described_class.similar?(a, b, similarity: 1).should be false end it 'is able to tell similiar empty hash' do described_class.similar?({}, {}, similarity: 1).should be true end it 'is able to tell similiar empty array' do described_class.similar?([], [], similarity: 1).should be true end it 'is able to tell similiar hash with values within tolerance' do a = { 'a' => 1.5, 'b' => 2.25, 'c' => 3, 'd' => 4, 'e' => 5 } b = { 'a' => 1.503, 'b' => 2.22, 'c' => 3, 'e' => 5 } described_class.similar?(a, b, numeric_tolerance: 0.05).should be true described_class.similar?(a, b).should be false end it 'is able to tell numbers and strings' do described_class.similar?(1, 2).should be false described_class.similar?('a', 'b').should be false described_class.similar?('a', [1, 2, 3]).should be false described_class.similar?(1, 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5).should be false end it 'is able to tell true when similarity == 0.5' do a = { 'value' => 'New1', 'onclick' => 'CreateNewDoc()' } b = { 'value' => 'New', 'onclick' => 'CreateNewDoc()' } described_class.similar?(a, b, similarity: 0.5).should be true end it 'is able to tell false when similarity == 0.5' do a = { 'value' => 'New1', 'onclick' => 'open()' } b = { 'value' => 'New', 'onclick' => 'CreateNewDoc()' } described_class.similar?(a, b, similarity: 0.5).should be false end describe '.compare_values' do it 'compares numeric values exactly when no tolerance' do expect(described_class.compare_values(10.004, 10.003)).to be false end it 'allows tolerance with numeric values' do expect(described_class.compare_values(10.004, 10.003, numeric_tolerance: 0.01)).to be true end it 'compares different objects without tolerance' do expect(described_class.compare_values('hats', 'ninjas')).to be false end it 'compares other objects with tolerance' do expect(described_class.compare_values('hats', 'ninjas', numeric_tolerance: 0.01)).to be false end it 'compares same objects without tolerance' do expect(described_class.compare_values('horse', 'horse')).to be true end it 'compares strings for spaces exactly by default' do expect(described_class.compare_values(' horse', 'horse')).to be false end it 'compares strings for capitalization exactly by default' do expect(described_class.compare_values('horse', 'Horse')).to be false end it 'strips strings before comparing when requested' do expect(described_class.compare_values(' horse', 'horse', strip: true)).to be true end it 'ignores string case when requested' do expect(described_class.compare_values('horse', 'Horse', case_insensitive: true)).to be true end end end hashdiff-1.0.0/spec/hashdiff/patch_spec.rb0000644000004100000410000001331413522344705020467 0ustar www-datawww-data# frozen_string_literal: true require 'spec_helper' describe Hashdiff do it 'is able to patch key addition' do a = { 'a' => 3, 'c' => 11, 'd' => 45, 'e' => 100, 'f' => 200 } b = { 'a' => 3, 'c' => 11, 'd' => 45, 'e' => 100, 'f' => 200, 'g' => 300 } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 3, 'c' => 11, 'd' => 45, 'e' => 100, 'f' => 200 } b = { 'a' => 3, 'c' => 11, 'd' => 45, 'e' => 100, 'f' => 200, 'g' => 300 } described_class.unpatch!(b, diff).should == a end it 'is able to patch value type changes' do a = { 'a' => 3 } b = { 'a' => { 'a1' => 1, 'a2' => 2 } } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 3 } b = { 'a' => { 'a1' => 1, 'a2' => 2 } } described_class.unpatch!(b, diff).should == a end it 'is able to patch value array <=> []' do a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1, 'b' => [] } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1, 'b' => [] } described_class.unpatch!(b, diff).should == a end it 'is able to patch value array <=> nil' do a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1, 'b' => nil } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1, 'b' => nil } described_class.unpatch!(b, diff).should == a end it 'is able to patch array value removal' do a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1 } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b' => [1, 2] } b = { 'a' => 1 } described_class.unpatch!(b, diff).should == a end it 'is able to patch array under hash key with non-word characters' do a = { 'a' => 1, 'b-b' => [1, 2] } b = { 'a' => 1, 'b-b' => [2, 1] } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b-b' => [1, 2] } b = { 'a' => 1, 'b-b' => [2, 1] } described_class.unpatch!(b, diff).should == a end it 'is able to patch hash value removal' do a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1 } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1 } described_class.unpatch!(b, diff).should == a end it 'is able to patch value hash <=> {}' do a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1, 'b' => {} } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1, 'b' => {} } described_class.unpatch!(b, diff).should == a end it 'is able to patch value hash <=> nil' do a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1, 'b' => nil } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1, 'b' => nil } described_class.unpatch!(b, diff).should == a end it 'is able to patch value nil removal' do a = { 'a' => 1, 'b' => nil } b = { 'a' => 1 } diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b' => nil } b = { 'a' => 1 } described_class.unpatch!(b, diff).should == a end it 'is able to patch similar objects between arrays' do a = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 }, 3] b = [1, { 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 }] diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 }, 3] b = [1, { 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 }] described_class.unpatch!(b, diff).should == a end it 'is able to patch similar & equal objects between arrays' do a = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 }, { 'x' => 5, 'y' => 6, 'z' => 3 }, 1] b = [1, { 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 }] diff = described_class.diff(a, b) expect(described_class.patch!(a, diff)).to eq(b) a = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 }, { 'x' => 5, 'y' => 6, 'z' => 3 }, 1] b = [1, { 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 }] described_class.unpatch!(b, diff).should == a end it 'is able to patch hash value removal with custom delimiter' do a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1, 'b' => { 'b1' => 3 } } diff = described_class.diff(a, b, delimiter: "\n") expect(described_class.patch!(a, diff, delimiter: "\n")).to eq(b) a = { 'a' => 1, 'b' => { 'b1' => 1, 'b2' => 2 } } b = { 'a' => 1, 'b' => { 'b1' => 3 } } described_class.unpatch!(b, diff, delimiter: "\n").should == a end it 'is able to patch when the diff is generated with an array_path' do a = { 'a' => 1, 'b' => 1 } b = { 'a' => 1, 'b' => 2 } diff = described_class.diff(a, b, array_path: true) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, 'b' => 1 } b = { 'a' => 1, 'b' => 2 } described_class.unpatch!(b, diff).should == a end it 'is able to use non string keys when diff is generated with an array_path' do a = { 'a' => 1, :a => 2, 0 => 3 } b = { 'a' => 5, :a => 6, 0 => 7 } diff = described_class.diff(a, b, array_path: true) expect(described_class.patch!(a, diff)).to eq(b) a = { 'a' => 1, :a => 2, 0 => 3 } b = { 'a' => 5, :a => 6, 0 => 7 } described_class.unpatch!(b, diff).should == a end end hashdiff-1.0.0/spec/hashdiff/diff_array_spec.rb0000644000004100000410000000311313522344705021472 0ustar www-datawww-data# frozen_string_literal: true require 'spec_helper' describe Hashdiff do it 'is able to diff two equal array' do a = [1, 2, 3] b = [1, 2, 3] diff = described_class.diff_array_lcs(a, b) diff.should == [] end it 'is able to diff two arrays with one element in common' do a = [1, 2, 3] b = [1, 8, 7] diff = described_class.diff_array_lcs(a, b) diff.should == [['-', 2, 3], ['-', 1, 2], ['+', 1, 8], ['+', 2, 7]] end it 'is able to diff two arrays with nothing in common' do a = [1, 2] b = [] diff = described_class.diff_array_lcs(a, b) diff.should == [['-', 1, 2], ['-', 0, 1]] end it 'is able to diff an empty array with an non-empty array' do a = [] b = [1, 2] diff = described_class.diff_array_lcs(a, b) diff.should == [['+', 0, 1], ['+', 1, 2]] end it 'is able to diff two arrays with two elements in common' do a = [1, 3, 5, 7] b = [2, 3, 7, 5] diff = described_class.diff_array_lcs(a, b) diff.should == [['-', 0, 1], ['+', 0, 2], ['+', 2, 7], ['-', 4, 7]] end it 'is able to test two arrays with two common elements in different order' do a = [1, 3, 4, 7] b = [2, 3, 7, 5] diff = described_class.diff_array_lcs(a, b) diff.should == [['-', 0, 1], ['+', 0, 2], ['-', 2, 4], ['+', 3, 5]] end it 'is able to diff two arrays with similar elements' do a = [{ 'a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5 }, 3] b = [1, { 'a' => 1, 'b' => 2, 'c' => 3, 'e' => 5 }] diff = described_class.diff_array_lcs(a, b) diff.should == [['+', 0, 1], ['-', 2, 3]] end end hashdiff-1.0.0/spec/hashdiff/best_diff_spec.rb0000644000004100000410000000457113522344705021322 0ustar www-datawww-data# frozen_string_literal: true require 'spec_helper' describe Hashdiff do it 'is able to best diff' do a = { 'x' => [{ 'a' => 1, 'c' => 3, 'e' => 5 }, { 'y' => 3 }] } b = { 'x' => [{ 'a' => 1, 'b' => 2, 'e' => 5 }] } diff = described_class.best_diff(a, b) diff.should == [['-', 'x[0].c', 3], ['+', 'x[0].b', 2], ['-', 'x[1]', { 'y' => 3 }]] end it 'uses custom delimiter when provided' do a = { 'x' => [{ 'a' => 1, 'c' => 3, 'e' => 5 }, { 'y' => 3 }] } b = { 'x' => [{ 'a' => 1, 'b' => 2, 'e' => 5 }] } diff = described_class.best_diff(a, b, delimiter: "\t") diff.should == [['-', "x[0]\tc", 3], ['+', "x[0]\tb", 2], ['-', 'x[1]', { 'y' => 3 }]] end it 'uses custom comparison when provided' do a = { 'x' => [{ 'a' => 'foo', 'c' => 'goat', 'e' => 'snake' }, { 'y' => 'baz' }] } b = { 'x' => [{ 'a' => 'bar', 'b' => 'cow', 'e' => 'puppy' }] } diff = described_class.best_diff(a, b) do |path, obj1, obj2| case path when /^x\[.\]\..$/ obj1.length == obj2.length if obj1 && obj2 end end diff.should == [['-', 'x[0].c', 'goat'], ['+', 'x[0].b', 'cow'], ['-', 'x[1]', { 'y' => 'baz' }]] end it 'is able to best diff array in hash' do a = { 'menu' => { 'id' => 'file', 'value' => 'File', 'popup' => { 'menuitem' => [ { 'value' => 'New', 'onclick' => 'CreateNewDoc()' }, { 'value' => 'Close', 'onclick' => 'CloseDoc()' } ] } } } b = { 'menu' => { 'id' => 'file 2', 'value' => 'File', 'popup' => { 'menuitem' => [ { 'value' => 'New1', 'onclick' => 'CreateNewDoc()' }, { 'value' => 'Open', 'onclick' => 'OpenDoc()' }, { 'value' => 'Close', 'onclick' => 'CloseDoc()' } ] } } } diff = described_class.best_diff(a, b) diff.should == [ ['~', 'menu.id', 'file', 'file 2'], ['~', 'menu.popup.menuitem[0].value', 'New', 'New1'], ['+', 'menu.popup.menuitem[1]', { 'value' => 'Open', 'onclick' => 'OpenDoc()' }] ] end it 'is able to have an array_path specified' do a = { 'x' => [{ 'a' => 1, 'c' => 3, 'e' => 5 }, { 'y' => 3 }] } b = { 'x' => [{ 'a' => 1, 'b' => 2, 'e' => 5 }] } diff = described_class.best_diff(a, b, array_path: true) diff.should == [['-', ['x', 0, 'c'], 3], ['+', ['x', 0, 'b'], 2], ['-', ['x', 1], { 'y' => 3 }]] end end hashdiff-1.0.0/spec/hashdiff/lcs_spec.rb0000644000004100000410000000353013522344705020150 0ustar www-datawww-data# frozen_string_literal: true require 'spec_helper' describe Hashdiff do it 'is able to find LCS between two equal array' do a = [1, 2, 3] b = [1, 2, 3] lcs = described_class.lcs(a, b) lcs.should == [[0, 0], [1, 1], [2, 2]] end it 'is able to find LCS between two close arrays' do a = [1.05, 2, 3.25] b = [1.06, 2, 3.24] lcs = described_class.lcs(a, b, numeric_tolerance: 0.1) lcs.should == [[0, 0], [1, 1], [2, 2]] end it 'strips strings when finding LCS if requested' do a = %w[foo bar baz] b = [' foo', 'bar', 'zab'] lcs = described_class.lcs(a, b, strip: true) lcs.should == [[0, 0], [1, 1]] end it 'is able to find LCS with one common elements' do a = [1, 2, 3] b = [1, 8, 7] lcs = described_class.lcs(a, b) lcs.should == [[0, 0]] end it 'is able to find LCS with two common elements' do a = [1, 3, 5, 7] b = [2, 3, 7, 5] lcs = described_class.lcs(a, b) lcs.should == [[1, 1], [2, 3]] end it 'is able to find LCS with two close elements' do a = [1, 3.05, 5, 7] b = [2, 3.06, 7, 5] lcs = described_class.lcs(a, b, numeric_tolerance: 0.1) lcs.should == [[1, 1], [2, 3]] end it 'is able to find LCS with two common elements in different ordering' do a = [1, 3, 4, 7] b = [2, 3, 7, 5] lcs = described_class.lcs(a, b) lcs.should == [[1, 1], [3, 2]] end it 'is able to find LCS with a similarity value' do a = [ { 'value' => 'New', 'onclick' => 'CreateNewDoc()' }, { 'value' => 'Close', 'onclick' => 'CloseDoc()' } ] b = [ { 'value' => 'New1', 'onclick' => 'CreateNewDoc()' }, { 'value' => 'Open', 'onclick' => 'OpenDoc()' }, { 'value' => 'Close', 'onclick' => 'CloseDoc()' } ] lcs = described_class.lcs(a, b, similarity: 0.5) lcs.should == [[0, 0], [1, 2]] end end hashdiff-1.0.0/spec/spec_helper.rb0000644000004100000410000000042013522344705017065 0ustar www-datawww-data# frozen_string_literal: true $LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib') require 'rubygems' require 'rspec' require 'rspec/autorun' require 'hashdiff' RSpec.configure do |config| config.mock_framework = :rspec config.include RSpec::Matchers end hashdiff-1.0.0/.rubocop.yml0000644000004100000410000000107413522344705015575 0ustar www-datawww-datarequire: rubocop-rspec Metrics/PerceivedComplexity: Enabled: false Metrics/CyclomaticComplexity: Enabled: false Metrics/MethodLength: Enabled: false Metrics/AbcSize: Enabled: false Metrics/LineLength: Enabled: false Metrics/ClassLength: Enabled: false Metrics/BlockLength: Enabled: false Metrics/ModuleLength: Enabled: false Style/Documentation: Enabled: false Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always Style/NumericPredicate: Enabled: false Style/RedundantFreeze: Enabled: false RSpec/ExampleLength: Enabled: false hashdiff-1.0.0/.gitignore0000644000004100000410000000056213522344705015314 0ustar www-datawww-data# See http://help.github.com/ignore-files/ for more about ignoring files. # # If you find yourself ignoring temporary files generated by your text editor # or operating system, you probably want to add a global ignore instead: # git config --global core.excludesfile ~/.gitignore_global # Ignore bundler config /.bundle /doc /.yardoc /Gemfile.lock *.swp *.bak *.gem hashdiff-1.0.0/LICENSE0000644000004100000410000000203713522344705014330 0ustar www-datawww-dataCopyright (c) 2012 Liu Fengyun Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. hashdiff-1.0.0/Rakefile0000644000004100000410000000051713522344705014771 0ustar www-datawww-data# frozen_string_literal: true $LOAD_PATH.push File.expand_path('lib', __dir__) require 'rubocop/rake_task' require 'bundler' Bundler::GemHelper.install_tasks require 'rspec/core/rake_task' RuboCop::RakeTask.new task default: %w[spec rubocop] RSpec::Core::RakeTask.new(:spec) do |spec| spec.pattern = './spec/**/*_spec.rb' end hashdiff-1.0.0/lib/0000755000004100000410000000000013522344705014067 5ustar www-datawww-datahashdiff-1.0.0/lib/hashdiff/0000755000004100000410000000000013522344705015643 5ustar www-datawww-datahashdiff-1.0.0/lib/hashdiff/compare_hashes.rb0000644000004100000410000000302513522344705021151 0ustar www-datawww-data# frozen_string_literal: true module Hashdiff # @private # Used to compare hashes class CompareHashes class << self def call(obj1, obj2, opts = {}) return [] if obj1.empty? && obj2.empty? obj1_keys = obj1.keys obj2_keys = obj2.keys added_keys = (obj2_keys - obj1_keys).sort_by(&:to_s) common_keys = (obj1_keys & obj2_keys).sort_by(&:to_s) deleted_keys = (obj1_keys - obj2_keys).sort_by(&:to_s) result = [] # add deleted properties deleted_keys.each do |k| change_key = Hashdiff.prefix_append_key(opts[:prefix], k, opts) custom_result = Hashdiff.custom_compare(opts[:comparison], change_key, obj1[k], nil) if custom_result result.concat(custom_result) else result << ['-', change_key, obj1[k]] end end # recursive comparison for common keys common_keys.each do |k| prefix = Hashdiff.prefix_append_key(opts[:prefix], k, opts) result.concat(Hashdiff.diff(obj1[k], obj2[k], opts.merge(prefix: prefix))) end # added properties added_keys.each do |k| change_key = Hashdiff.prefix_append_key(opts[:prefix], k, opts) custom_result = Hashdiff.custom_compare(opts[:comparison], change_key, nil, obj2[k]) if custom_result result.concat(custom_result) else result << ['+', change_key, obj2[k]] end end result end end end end hashdiff-1.0.0/lib/hashdiff/version.rb0000644000004100000410000000011613522344705017653 0ustar www-datawww-data# frozen_string_literal: true module Hashdiff VERSION = '1.0.0'.freeze end hashdiff-1.0.0/lib/hashdiff/patch.rb0000644000004100000410000000473413522344705017277 0ustar www-datawww-data# frozen_string_literal: true # # This module provides methods to diff two hash, patch and unpatch hash # module Hashdiff # Apply patch to object # # @param [Hash, Array] obj the object to be patched, can be an Array or a Hash # @param [Array] changes e.g. [[ '+', 'a.b', '45' ], [ '-', 'a.c', '5' ], [ '~', 'a.x', '45', '63']] # @param [Hash] options supports following keys: # * :delimiter (String) ['.'] delimiter string for representing nested keys in changes array # # @return the object after patch # # @since 0.0.1 def self.patch!(obj, changes, options = {}) delimiter = options[:delimiter] || '.' changes.each do |change| parts = change[1] parts = decode_property_path(parts, delimiter) unless parts.is_a?(Array) last_part = parts.last parent_node = node(obj, parts[0, parts.size - 1]) if change[0] == '+' if parent_node.is_a?(Array) parent_node.insert(last_part, change[2]) else parent_node[last_part] = change[2] end elsif change[0] == '-' if parent_node.is_a?(Array) parent_node.delete_at(last_part) else parent_node.delete(last_part) end elsif change[0] == '~' parent_node[last_part] = change[3] end end obj end # Unpatch an object # # @param [Hash, Array] obj the object to be unpatched, can be an Array or a Hash # @param [Array] changes e.g. [[ '+', 'a.b', '45' ], [ '-', 'a.c', '5' ], [ '~', 'a.x', '45', '63']] # @param [Hash] options supports following keys: # * :delimiter (String) ['.'] delimiter string for representing nested keys in changes array # # @return the object after unpatch # # @since 0.0.1 def self.unpatch!(obj, changes, options = {}) delimiter = options[:delimiter] || '.' changes.reverse_each do |change| parts = change[1] parts = decode_property_path(parts, delimiter) unless parts.is_a?(Array) last_part = parts.last parent_node = node(obj, parts[0, parts.size - 1]) if change[0] == '+' if parent_node.is_a?(Array) parent_node.delete_at(last_part) else parent_node.delete(last_part) end elsif change[0] == '-' if parent_node.is_a?(Array) parent_node.insert(last_part, change[2]) else parent_node[last_part] = change[2] end elsif change[0] == '~' parent_node[last_part] = change[2] end end obj end end hashdiff-1.0.0/lib/hashdiff/linear_compare_array.rb0000644000004100000410000001114513522344705022350 0ustar www-datawww-data# frozen_string_literal: true module Hashdiff # @private # # Used to compare arrays in a linear complexity, which produces longer diffs # than using the lcs algorithm but is considerably faster class LinearCompareArray def self.call(old_array, new_array, options = {}) instance = new(old_array, new_array, options) instance.call end def call return [] if old_array.empty? && new_array.empty? self.old_index = 0 self.new_index = 0 # by comparing the array lengths we can expect that a number of items # are either added or removed self.expected_additions = new_array.length - old_array.length loop do if extra_items_in_old_array? append_deletion(old_array[old_index], old_index) elsif extra_items_in_new_array? append_addition(new_array[new_index], new_index) else compare_at_index end self.old_index = old_index + 1 self.new_index = new_index + 1 break if iterated_through_both_arrays? end changes end private attr_reader :old_array, :new_array, :options, :additions, :deletions, :differences attr_accessor :old_index, :new_index, :expected_additions def initialize(old_array, new_array, options) @old_array = old_array @new_array = new_array @options = { prefix: '' }.merge!(options) @additions = [] @deletions = [] @differences = [] end def extra_items_in_old_array? old_index < old_array.length && new_index >= new_array.length end def extra_items_in_new_array? new_index < new_array.length && old_index >= old_array.length end def iterated_through_both_arrays? old_index >= old_array.length && new_index >= new_array.length end def compare_at_index difference = item_difference(old_array[old_index], new_array[new_index], old_index) return if difference.empty? index_after_additions = index_of_match_after_additions append_addititions_before_match(index_after_additions) index_after_deletions = index_of_match_after_deletions append_deletions_before_match(index_after_deletions) match = index_after_additions || index_after_deletions append_differences(difference) unless match end def item_difference(old_item, new_item, item_index) prefix = Hashdiff.prefix_append_array_index(options[:prefix], item_index, options) Hashdiff.diff(old_item, new_item, options.merge(prefix: prefix)) end # look ahead in the new array to see if the current item appears later # thereby having new items added def index_of_match_after_additions return unless expected_additions > 0 (1..expected_additions).each do |i| next_difference = item_difference( old_array[old_index], new_array[new_index + i], old_index ) return new_index + i if next_difference.empty? end nil end # look ahead in the old array to see if the current item appears later # thereby having items removed def index_of_match_after_deletions return unless expected_additions < 0 (1..(expected_additions.abs)).each do |i| next_difference = item_difference( old_array[old_index + i], new_array[new_index], old_index ) return old_index + i if next_difference.empty? end nil end def append_addititions_before_match(match_index) return unless match_index (new_index...match_index).each { |i| append_addition(new_array[i], i) } self.expected_additions = expected_additions - (match_index - new_index) self.new_index = match_index end def append_deletions_before_match(match_index) return unless match_index (old_index...match_index).each { |i| append_deletion(old_array[i], i) } self.expected_additions = expected_additions + (match_index - new_index) self.old_index = match_index end def append_addition(item, index) key = Hashdiff.prefix_append_array_index(options[:prefix], index, options) additions << ['+', key, item] end def append_deletion(item, index) key = Hashdiff.prefix_append_array_index(options[:prefix], index, options) deletions << ['-', key, item] end def append_differences(difference) differences.concat(difference) end def changes # this algorithm only allows there to be additions or deletions # deletions are reverse so they don't change the index of earlier items differences + additions + deletions.reverse end end end hashdiff-1.0.0/lib/hashdiff/lcs_compare_arrays.rb0000644000004100000410000000153413522344705022043 0ustar www-datawww-data# frozen_string_literal: true module Hashdiff # @private # Used to compare arrays using the lcs algorithm class LcsCompareArrays class << self def call(obj1, obj2, opts = {}) result = [] changeset = Hashdiff.diff_array_lcs(obj1, obj2, opts) do |lcs| # use a's index for similarity lcs.each do |pair| prefix = Hashdiff.prefix_append_array_index(opts[:prefix], pair[0], opts) result.concat(Hashdiff.diff(obj1[pair[0]], obj2[pair[1]], opts.merge(prefix: prefix))) end end changeset.each do |change| next if change[0] != '-' && change[0] != '+' change_key = Hashdiff.prefix_append_array_index(opts[:prefix], change[1], opts) result << [change[0], change_key, change[2]] end result end end end end hashdiff-1.0.0/lib/hashdiff/diff.rb0000644000004100000410000001527413522344705017111 0ustar www-datawww-data# frozen_string_literal: true module Hashdiff # Best diff two objects, which tries to generate the smallest change set using different similarity values. # # Hashdiff.best_diff is useful in case of comparing two objects which include similar hashes in arrays. # # @param [Array, Hash] obj1 # @param [Array, Hash] obj2 # @param [Hash] options the options to use when comparing # * :strict (Boolean) [true] whether numeric values will be compared on type as well as value. Set to false to allow comparing Integer, Float, BigDecimal to each other # * :delimiter (String) ['.'] the delimiter used when returning nested key references # * :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. # * :strip (Boolean) [false] whether or not to call #strip on strings before comparing # * :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. # * :use_lcs (Boolean) [true] whether or not to use an implementation of the Longest common subsequence algorithm for comparing arrays, produces better diffs but is slower. # # @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. # # @return [Array] an array of changes. # e.g. [[ '+', 'a.b', '45' ], [ '-', 'a.c', '5' ], [ '~', 'a.x', '45', '63']] # # @example # a = {'x' => [{'a' => 1, 'c' => 3, 'e' => 5}, {'y' => 3}]} # b = {'x' => [{'a' => 1, 'b' => 2, 'e' => 5}] } # diff = Hashdiff.best_diff(a, b) # diff.should == [['-', 'x[0].c', 3], ['+', 'x[0].b', 2], ['-', 'x[1].y', 3], ['-', 'x[1]', {}]] # # @since 0.0.1 def self.best_diff(obj1, obj2, options = {}, &block) options[:comparison] = block if block_given? opts = { similarity: 0.3 }.merge!(options) diffs1 = diff(obj1, obj2, opts) count1 = count_diff diffs1 opts = { similarity: 0.5 }.merge!(options) diffs2 = diff(obj1, obj2, opts) count2 = count_diff diffs2 opts = { similarity: 0.8 }.merge!(options) diffs3 = diff(obj1, obj2, opts) count3 = count_diff diffs3 count, diffs = count1 < count2 ? [count1, diffs1] : [count2, diffs2] count < count3 ? diffs : diffs3 end # Compute the diff of two hashes or arrays # # @param [Array, Hash] obj1 # @param [Array, Hash] obj2 # @param [Hash] options the options to use when comparing # * :strict (Boolean) [true] whether numeric values will be compared on type as well as value. Set to false to allow comparing Integer, Float, BigDecimal to each other # * :similarity (Numeric) [0.8] should be between (0, 1]. Meaningful if there are similar hashes in arrays. See {best_diff}. # * :delimiter (String) ['.'] the delimiter used when returning nested key references # * :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. # * :strip (Boolean) [false] whether or not to call #strip on strings before comparing # * :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. # * :use_lcs (Boolean) [true] whether or not to use an implementation of the Longest common subsequence algorithm for comparing arrays, produces better diffs but is slower. # # # @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. # # @return [Array] an array of changes. # e.g. [[ '+', 'a.b', '45' ], [ '-', 'a.c', '5' ], [ '~', 'a.x', '45', '63']] # # @example # a = {"a" => 1, "b" => {"b1" => 1, "b2" =>2}} # b = {"a" => 1, "b" => {}} # # diff = Hashdiff.diff(a, b) # diff.should == [['-', 'b.b1', 1], ['-', 'b.b2', 2]] # # @since 0.0.1 def self.diff(obj1, obj2, options = {}, &block) opts = { prefix: '', similarity: 0.8, delimiter: '.', strict: true, strip: false, numeric_tolerance: 0, array_path: false, use_lcs: true }.merge!(options) opts[:prefix] = [] if opts[:array_path] && opts[:prefix] == '' opts[:comparison] = block if block_given? # prefer to compare with provided block result = custom_compare(opts[:comparison], opts[:prefix], obj1, obj2) return result if result return [] if obj1.nil? && obj2.nil? return [['~', opts[:prefix], obj1, obj2]] if obj1.nil? || obj2.nil? return [['~', opts[:prefix], obj1, obj2]] unless comparable?(obj1, obj2, opts[:strict]) return LcsCompareArrays.call(obj1, obj2, opts) if obj1.is_a?(Array) && opts[:use_lcs] return LinearCompareArray.call(obj1, obj2, opts) if obj1.is_a?(Array) && !opts[:use_lcs] return CompareHashes.call(obj1, obj2, opts) if obj1.is_a?(Hash) return [] if compare_values(obj1, obj2, opts) [['~', opts[:prefix], obj1, obj2]] end # @private # # diff array using LCS algorithm def self.diff_array_lcs(arraya, arrayb, options = {}) return [] if arraya.empty? && arrayb.empty? change_set = [] if arraya.empty? arrayb.each_index do |index| change_set << ['+', index, arrayb[index]] end return change_set end if arrayb.empty? arraya.each_index do |index| i = arraya.size - index - 1 change_set << ['-', i, arraya[i]] end return change_set end opts = { prefix: '', similarity: 0.8, delimiter: '.' }.merge!(options) links = lcs(arraya, arrayb, opts) # yield common yield links if block_given? # padding the end links << [arraya.size, arrayb.size] last_x = -1 last_y = -1 links.each do |pair| x, y = pair # remove from a, beginning from the end (x > last_x + 1) && (x - last_x - 2).downto(0).each do |i| change_set << ['-', last_y + i + 1, arraya[i + last_x + 1]] end # add from b, beginning from the head (y > last_y + 1) && 0.upto(y - last_y - 2).each do |i| change_set << ['+', last_y + i + 1, arrayb[i + last_y + 1]] end # update flags last_x = x last_y = y end change_set end end hashdiff-1.0.0/lib/hashdiff/lcs.rb0000644000004100000410000000326713522344705016761 0ustar www-datawww-data# frozen_string_literal: true module Hashdiff # @private # # caculate array difference using LCS algorithm # http://en.wikipedia.org/wiki/Longest_common_subsequence_problem def self.lcs(arraya, arrayb, options = {}) return [] if arraya.empty? || arrayb.empty? opts = { similarity: 0.8 }.merge!(options) opts[:prefix] = prefix_append_array_index(opts[:prefix], '*', opts) a_start = b_start = 0 a_finish = arraya.size - 1 b_finish = arrayb.size - 1 vector = [] lcs = [] (b_start..b_finish).each do |bi| lcs[bi] = [] (a_start..a_finish).each do |ai| if similar?(arraya[ai], arrayb[bi], opts) topleft = (ai > 0) && (bi > 0) ? lcs[bi - 1][ai - 1][1] : 0 lcs[bi][ai] = [:topleft, topleft + 1] elsif (top = bi > 0 ? lcs[bi - 1][ai][1] : 0) left = ai > 0 ? lcs[bi][ai - 1][1] : 0 count = top > left ? top : left direction = if top > left :top elsif top < left :left elsif bi.zero? :top elsif ai.zero? :left else :both end lcs[bi][ai] = [direction, count] end end end x = a_finish y = b_finish while (x >= 0) && (y >= 0) && (lcs[y][x][1] > 0) if lcs[y][x][0] == :both x -= 1 elsif lcs[y][x][0] == :topleft vector.insert(0, [x, y]) x -= 1 y -= 1 elsif lcs[y][x][0] == :top y -= 1 elsif lcs[y][x][0] == :left x -= 1 end end vector end end hashdiff-1.0.0/lib/hashdiff/util.rb0000644000004100000410000000721313522344705017150 0ustar www-datawww-data# frozen_string_literal: true module Hashdiff # @private # # judge whether two objects are similar def self.similar?(obja, objb, options = {}) return compare_values(obja, objb, options) if !options[:comparison] && !any_hash_or_array?(obja, objb) count_a = count_nodes(obja) count_b = count_nodes(objb) return true if (count_a + count_b).zero? opts = { similarity: 0.8 }.merge!(options) diffs = count_diff diff(obja, objb, opts) (1 - diffs.to_f / (count_a + count_b).to_f) >= opts[:similarity] end # @private # # count node differences def self.count_diff(diffs) diffs.inject(0) do |sum, item| old_change_count = count_nodes(item[2]) new_change_count = count_nodes(item[3]) sum + (old_change_count + new_change_count) end end # @private # # count total nodes for an object def self.count_nodes(obj) return 0 unless obj count = 0 if obj.is_a?(Array) obj.each { |e| count += count_nodes(e) } elsif obj.is_a?(Hash) obj.each_value { |v| count += count_nodes(v) } else return 1 end count end # @private # # decode property path into an array # @param [String] path Property-string # @param [String] delimiter Property-string delimiter # # e.g. "a.b[3].c" => ['a', 'b', 3, 'c'] def self.decode_property_path(path, delimiter = '.') path.split(delimiter).inject([]) do |memo, part| if part =~ /^(.*)\[(\d+)\]$/ if !Regexp.last_match(1).empty? memo + [Regexp.last_match(1), Regexp.last_match(2).to_i] else memo + [Regexp.last_match(2).to_i] end else memo + [part] end end end # @private # # get the node of hash by given path parts def self.node(hash, parts) temp = hash parts.each do |part| temp = temp[part] end temp end # @private # # check for equality or "closeness" within given tolerance def self.compare_values(obj1, obj2, options = {}) if options[:numeric_tolerance].is_a?(Numeric) && obj1.is_a?(Numeric) && obj2.is_a?(Numeric) return (obj1 - obj2).abs <= options[:numeric_tolerance] end if options[:strip] == true obj1 = obj1.strip if obj1.respond_to?(:strip) obj2 = obj2.strip if obj2.respond_to?(:strip) end if options[:case_insensitive] == true obj1 = obj1.downcase if obj1.respond_to?(:downcase) obj2 = obj2.downcase if obj2.respond_to?(:downcase) end obj1 == obj2 end # @private # # check if objects are comparable def self.comparable?(obj1, obj2, strict = true) return true if (obj1.is_a?(Array) || obj1.is_a?(Hash)) && obj2.is_a?(obj1.class) return true if !strict && obj1.is_a?(Numeric) && obj2.is_a?(Numeric) obj1.is_a?(obj2.class) && obj2.is_a?(obj1.class) end # @private # # try custom comparison def self.custom_compare(method, key, obj1, obj2) return unless method res = method.call(key, obj1, obj2) # nil != false here return [['~', key, obj1, obj2]] if res == false return [] if res == true end def self.prefix_append_key(prefix, key, opts) if opts[:array_path] prefix + [key] else prefix.empty? ? key.to_s : "#{prefix}#{opts[:delimiter]}#{key}" end end def self.prefix_append_array_index(prefix, array_index, opts) if opts[:array_path] prefix + [array_index] else "#{prefix}[#{array_index}]" end end class << self private # @private # # checks if both objects are Arrays or Hashes def any_hash_or_array?(obja, objb) obja.is_a?(Array) || obja.is_a?(Hash) || objb.is_a?(Array) || objb.is_a?(Hash) end end end hashdiff-1.0.0/lib/hashdiff.rb0000644000004100000410000000041213522344705016165 0ustar www-datawww-data# frozen_string_literal: true require 'hashdiff/util' require 'hashdiff/compare_hashes' require 'hashdiff/lcs' require 'hashdiff/lcs_compare_arrays' require 'hashdiff/linear_compare_array' require 'hashdiff/diff' require 'hashdiff/patch' require 'hashdiff/version' hashdiff-1.0.0/.yardopts0000644000004100000410000000001513522344705015163 0ustar www-datawww-data--no-private hashdiff-1.0.0/Gemfile0000644000004100000410000000015513522344705014615 0ustar www-datawww-data# frozen_string_literal: true source 'http://rubygems.org' gemspec group :test do gem 'rake', '< 11' end hashdiff-1.0.0/changelog.md0000644000004100000410000000357213522344705015601 0ustar www-datawww-data# Change Log ## v1.0.0 2019-06-06 * Fix typo in readme (#72 @koic) * Fix Rubocop offence (#73 @koic) * Bumps version to v1.0.0 (#74 @jfelchner) ## v1.0.0.beta1 2019-06-06 * fix warnings in ci (#69 @y-yagi) * drop warnings of the constant change (#70 @jfelchner) ## v0.4.0 2019-05-28 * refactoring (#56 #57 #59 #61 krzysiek1507) * fix typo in README (#64 @pboling) * change HashDiff to Hashdiff (#65 @jfelchner) ## v0.3.9 2019-04-22 * Performance tweak (thanks @krzysiek1507: #51 #52 #53) ## v0.3.8 2018-12-30 * Add Rubocop and drops Ruby 1.9 support #47 ## v0.3.7 2017-10-08 * remove 1.8.7 support from gemspec #39 ## v0.3.6 2017-08-22 * add option `use_lcs` #35 ## v0.3.5 2017-08-06 * add option `array_path` #34 ## v0.3.4 2017-05-01 * performance improvement of `#similar?` #31 ## v0.3.2 2016-12-27 * replace `Fixnum` by `Integer` #28 ## v0.3.1 2016-11-24 * fix an error when a hash has mixed types #26 ## v0.3.0 2016-2-11 * support `:case_insensitive` option ## v0.2.3 2015-11-5 * improve performance of LCS algorithm #12 ## v0.2.2 2014-10-6 * make library 1.8.7 compatible ## v0.2.1 2014-7-13 * yield added/deleted keys for custom comparison ## v0.2.0 2014-3-29 * support custom comparison blocks * support `:strip`, `:numeric_tolerance` and `:strict` options ## v0.1.0 2013-8-25 * use options for parameters `:delimiter` and `:similarity` in interfaces ## v0.0.6 2013-3-2 * Add parameter for custom property-path delimiter. ## v0.0.5 2012-7-1 * fix a bug in judging whehter two objects are similiar. * add more spec test for `.best_diff` ## v0.0.4 2012-6-24 Main changes in this version is to output the whole object in addition & deletion, instead of recursely add/deletes the object. For example, `diff({a:2, c:[4, 5]}, {a:2}) will generate following output: [['-', 'c', [4, 5]]] instead of following: [['-', 'c[0]', 4], ['-', 'c[1]', 5], ['-', 'c', []]]