├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── ruby.yml ├── .gitignore ├── .ruby-version ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── hash_diff.gemspec ├── lib ├── hash_diff.rb └── hash_diff │ ├── comparison.rb │ └── version.rb └── spec ├── hash_diff ├── array_comparison_spec.rb └── comparison_spec.rb ├── hash_diff_spec.rb └── spec_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | version: 2 3 | updates: 4 | - package-ecosystem: "bundler" 5 | directory: "/" # Location of package manifests 6 | schedule: 7 | interval: "monthly" 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ master ] 9 | schedule: 10 | - cron: '39 15 * * 4' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'ruby' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v3 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v2 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 41 | 42 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 43 | # If this step fails, then you should remove it and run the build manually (see below) 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v2 46 | 47 | # ℹ️ Command-line programs to run using the OS shell. 48 | # 📚 https://git.io/JvXDl 49 | 50 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 51 | # and modify them (or add more) to build your code if your project 52 | # uses a compiled language 53 | 54 | #- run: | 55 | # make bootstrap 56 | # make release 57 | 58 | - name: Perform CodeQL Analysis 59 | uses: github/codeql-action/analyze@v2 -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | ruby-version: ['1.9', '2.0', '2.1', '2.2', '2.4', '2.5', '2.6', '2.7', '3.0', '3.1'] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby-version }} 23 | bundler-cache: true 24 | - name: Run tests 25 | run: bundle exec rake 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | vendor/ -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.3 -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in hash_diff.gemspec 4 | gemspec -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Zeal, LLC (dba. Coding Zeal) 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HashDiff 2 | 3 | [![Ruby](https://github.com/CodingZeal/hash_diff/actions/workflows/ruby.yml/badge.svg)](https://github.com/CodingZeal/hash_diff/actions/workflows/ruby.yml) [![Code Climate](https://codeclimate.com/github/CodingZeal/hash_diff.png)](https://codeclimate.com/github/CodingZeal/hash_diff) [![Gem Version](https://badge.fury.io/rb/hash_diff.png)](http://badge.fury.io/rb/hash_diff) 4 | 5 | Deep comparison of Ruby Hash objects 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | gem 'hash_diff' 12 | 13 | And then execute: 14 | 15 | $ bundle 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install hash_diff 20 | 21 | ## Usage 22 | 23 | Easily find the differences between two Ruby hashes. 24 | 25 | ```ruby 26 | left = { 27 | foo: 'bar', 28 | bar: 'foo', 29 | nested: { 30 | foo: 'bar', 31 | bar: { 32 | one: 'foo1' 33 | } 34 | }, 35 | num: 1, 36 | favorite_restaurant: "Shoney's" 37 | } 38 | 39 | right = { 40 | foo: 'bar2', 41 | bar: 'foo2', 42 | nested: { 43 | foo: 'bar2', 44 | bar: { 45 | two: 'foo2' 46 | } 47 | }, 48 | word: 'monkey', 49 | favorite_restaurant: nil 50 | } 51 | 52 | hash_diff = HashDiff::Comparison.new( left, right ) 53 | ``` 54 | 55 | Comparison#diff returns the left and right side differences 56 | 57 | ```ruby 58 | hash_diff.diff # => { foo: ["bar", "bar2"], bar: ["foo", "foo2"], nested: { foo: ["bar", "bar2"], bar: { one: ["foo1", HashDiff::NO_VALUE], two: [HashDiff::NO_VALUE, "foo2"] } }, num: [1, HashDiff::NO_VALUE], word: [HashDiff::NO_VALUE, "monkey"], favorite_restaurant: ["Shoney's", nil] } 59 | ``` 60 | 61 | You can also compare two arrays. The comparison is sensitive to the order of the elements in the array. 62 | 63 | #### Missing keys 64 | 65 | When there is a key that exists on one side it will return `HashDiff::NO_VALUE` to represent a missing key. 66 | 67 | Comparison#left_diff returns only the left side differences 68 | 69 | ```ruby 70 | hash_diff.left_diff # => {:foo=>"bar2", :bar=>"foo2", :nested=>{:foo=>"bar2", :bar=>{:one=>HashDiff::NO_VALUE, :two=>"foo2"}}, :num=>HashDiff::NO_VALUE, :favorite_restaurant=>nil, :word=>"monkey"} 71 | ``` 72 | 73 | Comparison#right_diff returns only the right side differences 74 | 75 | ```ruby 76 | hash_diff.right_diff # => {:foo=>"bar", :bar=>"foo", :nested=>{:foo=>"bar", :bar=>{:one=>"foo1", :two=>HashDiff::NO_VALUE}}, :num=>1, :favorite_restaurant=>"Shoney's", :word=>HashDiff::NO_VALUE} 77 | ``` 78 | 79 | You can also use these shorthand methods 80 | 81 | ```ruby 82 | HashDiff.diff(left, right) 83 | HashDiff.left_diff(left, right) 84 | HashDiff.right_diff(left, right) 85 | ``` 86 | 87 | Hash#diff is not provided by default, and monkey patching is frowned upon by some, but to provide a one way shorthand call `HashDiff.patch!` 88 | 89 | ```ruby 90 | # run prior to implementation 91 | HashDiff.patch! 92 | 93 | left = { foo: 'bar', num: 1 } 94 | right = { foo: 'baz', num: 1 } 95 | 96 | left.diff(right) # => { foo: 'baz' } 97 | ``` 98 | 99 | ## License 100 | 101 | Authored by the Engineering Team of [Coding ZEAL](https://codingzeal.com?utm_source=github) 102 | 103 | Copyright (c) 2017 Zeal, LLC. Licensed under the [MIT license](https://opensource.org/licenses/MIT). 104 | 105 | ## Contributing 106 | 107 | 1. Fork it 108 | 2. Create your feature branch (`git checkout -b my-new-feature`) 109 | 3. Commit your changes (`git commit -am 'Add some feature'`) 110 | 4. Push to the branch (`git push origin my-new-feature`) 111 | 5. Create new Pull Request 112 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | begin 4 | require 'rspec/core/rake_task' 5 | RSpec::Core::RakeTask.new(:spec) 6 | task default: :spec 7 | rescue LoadError 8 | end 9 | -------------------------------------------------------------------------------- /hash_diff.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'hash_diff/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "hash_diff" 8 | spec.version = HashDiff::VERSION 9 | spec.authors = ["Coding Zeal", "Adam Cuppy", "Mike Bianco"] 10 | spec.email = ["info@codingzeal.com", "mike@mikebian.co"] 11 | spec.description = %q{Diff tool for deep Ruby hash comparison} 12 | spec.summary = %q{Deep Ruby Hash comparison} 13 | spec.homepage = "https://github.com/CodingZeal/hash_diff" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler" 22 | spec.add_development_dependency "rake" 23 | spec.add_development_dependency "rspec", "~> 3.1" 24 | end 25 | -------------------------------------------------------------------------------- /lib/hash_diff.rb: -------------------------------------------------------------------------------- 1 | require "hash_diff/version" 2 | require "hash_diff/comparison" 3 | 4 | module HashDiff 5 | class NO_VALUE; end 6 | 7 | def self.patch! 8 | Hash.class_eval do 9 | def diff(right) 10 | HashDiff.left_diff(self, right) 11 | end 12 | end unless Hash.new.respond_to?(:diff) 13 | end 14 | 15 | module_function 16 | 17 | def diff(*args) 18 | Comparison.new(*args).diff 19 | end 20 | 21 | def left_diff(*args) 22 | Comparison.new(*args).left_diff 23 | end 24 | 25 | def right_diff(*args) 26 | Comparison.new(*args).right_diff 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/hash_diff/comparison.rb: -------------------------------------------------------------------------------- 1 | module HashDiff 2 | class Comparison 3 | def initialize(left, right) 4 | @left = left 5 | @right = right 6 | end 7 | 8 | attr_reader :left, :right 9 | 10 | def diff 11 | @diff ||= find_differences { |l, r| [l, r] } 12 | end 13 | 14 | def left_diff 15 | @left_diff ||= find_differences { |_, r| r } 16 | end 17 | 18 | def right_diff 19 | @right_diff ||= find_differences { |l, _| l } 20 | end 21 | 22 | protected 23 | 24 | def find_differences(&reporter) 25 | combined_keys.each_with_object({ }, &comparison_strategy(reporter)) 26 | end 27 | 28 | private 29 | 30 | def comparison_strategy(reporter) 31 | lambda do |key, diff| 32 | diff[key] = report_difference(key, reporter) unless equal?(key) 33 | end 34 | end 35 | 36 | def combined_keys 37 | if hash?(left) && hash?(right) then 38 | (left.keys + right.keys).uniq 39 | elsif array?(left) && array?(right) then 40 | (0..[left.size, right.size].max).to_a 41 | else 42 | raise ArgumentError, "Don't know how to extract keys. Neither arrays nor hashes given" 43 | end 44 | end 45 | 46 | def equal?(key) 47 | value_with_default(left, key) == value_with_default(right, key) 48 | end 49 | 50 | def hash?(value) 51 | value.is_a?(Hash) 52 | end 53 | 54 | def array?(value) 55 | value.is_a?(Array) 56 | end 57 | 58 | def comparable_hash?(key) 59 | hash?(left[key]) && hash?(right[key]) 60 | end 61 | 62 | def comparable_array?(key) 63 | array?(left[key]) && array?(right[key]) 64 | end 65 | 66 | def report_difference(key, reporter) 67 | if comparable_hash?(key) 68 | self.class.new(left[key], right[key]).find_differences(&reporter) 69 | elsif comparable_array?(key) 70 | self.class.new(left[key], right[key]).find_differences(&reporter) 71 | else 72 | reporter.call( 73 | value_with_default(left, key), 74 | value_with_default(right, key) 75 | ) 76 | end 77 | end 78 | 79 | def value_with_default(obj, key) 80 | obj.fetch(key, NO_VALUE) 81 | end 82 | end 83 | end 84 | 85 | -------------------------------------------------------------------------------- /lib/hash_diff/version.rb: -------------------------------------------------------------------------------- 1 | module HashDiff 2 | VERSION = "1.1.1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/hash_diff/array_comparison_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe HashDiff::Comparison do 4 | let(:left) { 5 | [ 6 | { 7 | foo: 'bar', 8 | bar: 'foo', 9 | }, 10 | { 11 | nested: { 12 | foo: 'bar', 13 | bar: { 14 | one: 'foo1' 15 | } 16 | }, 17 | }, 18 | { 19 | num: 1, 20 | word: nil 21 | } 22 | ] 23 | } 24 | 25 | def comparison(to_compare) 26 | HashDiff::Comparison.new(left, to_compare) 27 | end 28 | 29 | def right 30 | [ 31 | { 32 | foo: 'bar', 33 | bar: 'foo', 34 | }, 35 | { 36 | nested: { 37 | foo: 'bar', 38 | bar: { 39 | one: 'foo1' 40 | } 41 | }, 42 | }, 43 | { 44 | num: 1, 45 | word: nil 46 | } 47 | ] 48 | end 49 | 50 | describe 'when arrays are the same' do 51 | it 'properly determines equality' do 52 | expect(comparison(right).diff).to be_empty 53 | end 54 | 55 | it 'handles empty arrays' do 56 | expect(HashDiff::Comparison.new([], []).diff).to be_empty 57 | end 58 | end 59 | 60 | describe 'when arrays are different' do 61 | it 'reports arrays as not equal with a different order' do 62 | # move an item from the end to the beginning 63 | right_shuffled = right 64 | popped = right_shuffled.pop 65 | right_shuffled.unshift(popped) 66 | 67 | expect(comparison(right_shuffled).diff).to_not be_empty 68 | end 69 | 70 | it 'should a deep comparison' do 71 | right_with_extra_nested_element = right 72 | right_with_extra_nested_element[1][:nested][:bar][:two] = 'two' 73 | 74 | expect(comparison(right_with_extra_nested_element).diff).to_not be_empty 75 | end 76 | end 77 | 78 | end -------------------------------------------------------------------------------- /spec/hash_diff/comparison_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe HashDiff::Comparison do 4 | 5 | let(:app_v1_properties) { 6 | { 7 | foo: 'bar', 8 | bar: 'foo', 9 | nested: { 10 | foo: 'bar', 11 | bar: { 12 | one: 'foo1' 13 | } 14 | }, 15 | num: 1, 16 | word: nil 17 | } 18 | } 19 | let(:app_v2_properties) { 20 | { 21 | foo: 'bar2', 22 | bar: 'foo2', 23 | nested: { 24 | foo: 'bar2', 25 | bar: { 26 | two: 'foo2' 27 | } 28 | }, 29 | word: 'monkey' 30 | } 31 | } 32 | 33 | subject(:comparison) { 34 | HashDiff::Comparison.new(app_v1_properties, app_v2_properties) 35 | } 36 | 37 | describe "#diff" do 38 | subject { comparison.diff } 39 | 40 | context "when different" do 41 | let(:diff) { 42 | { 43 | foo: ["bar", "bar2"], 44 | bar: ["foo", "foo2"], 45 | nested: { 46 | foo: ["bar", "bar2"], 47 | bar: { 48 | one: ["foo1", HashDiff::NO_VALUE], 49 | two: [HashDiff::NO_VALUE, "foo2"] 50 | } 51 | }, 52 | num: [1, HashDiff::NO_VALUE], 53 | word: [nil, "monkey"] 54 | } 55 | } 56 | 57 | it { expect(subject).to eq diff } 58 | end 59 | 60 | context "when similar" do 61 | let(:app_v1_properties) { { foo: 'bar', bar: 'foo' } } 62 | 63 | context "in the same order" do 64 | let(:app_v2_properties) { app_v1_properties } 65 | 66 | it { expect(subject).to be_empty } 67 | end 68 | 69 | context "in a different order" do 70 | let(:app_v2_properties) { { bar: 'foo', foo: 'bar' } } 71 | 72 | it { expect(subject).to be_empty } 73 | end 74 | end 75 | 76 | context "when hashes have both symbol and string keys" do 77 | let(:app_v1_properties) { { foo: "bar" } } 78 | let(:app_v2_properties) { { "foo" => "bar" } } 79 | let(:diff) do 80 | { 81 | foo: ["bar", HashDiff::NO_VALUE], 82 | "foo" => [HashDiff::NO_VALUE, "bar"] 83 | } 84 | end 85 | 86 | it { expect(subject).to eq(diff) } 87 | end 88 | end 89 | 90 | describe "#left_diff" do 91 | subject { comparison.left_diff } 92 | 93 | let(:diff) { 94 | { 95 | foo: "bar2", 96 | bar: "foo2", 97 | nested: { 98 | foo: "bar2", 99 | bar: { 100 | one: HashDiff::NO_VALUE, 101 | two: "foo2" 102 | } 103 | }, 104 | num: HashDiff::NO_VALUE, 105 | word: "monkey" 106 | } 107 | } 108 | 109 | it { expect(subject).to eq diff } 110 | end 111 | 112 | describe "#right_diff" do 113 | subject { comparison.right_diff } 114 | 115 | let(:diff) { 116 | { 117 | foo: "bar", 118 | bar: "foo", 119 | nested: { 120 | foo: "bar", 121 | bar: { 122 | one: "foo1", 123 | two: HashDiff::NO_VALUE 124 | } 125 | }, 126 | num: 1, 127 | word: nil 128 | } 129 | } 130 | 131 | it { expect(subject).to eq diff } 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/hash_diff_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe HashDiff do 4 | describe ".diff" do 5 | subject { described_class.diff left, right } 6 | 7 | let(:left) { 8 | { foo: "bar" } 9 | } 10 | let(:right) { 11 | { foo: "bar2" } 12 | } 13 | 14 | it { expect(subject).to eq({ foo: ['bar', 'bar2']}) } 15 | end 16 | 17 | describe ".left_diff" do 18 | subject { described_class.left_diff left, right } 19 | 20 | let(:left) { 21 | { foo: "bar" } 22 | } 23 | let(:right) { 24 | { foo: "bar2" } 25 | } 26 | 27 | it { expect(subject).to eq({ foo: 'bar2' }) } 28 | end 29 | 30 | describe ".right_diff" do 31 | subject { described_class.right_diff left, right } 32 | 33 | let(:left) { 34 | { foo: "bar" } 35 | } 36 | let(:right) { 37 | { foo: "bar2" } 38 | } 39 | 40 | it { expect(subject).to eq({ foo: 'bar' }) } 41 | end 42 | 43 | describe ".patch!" do 44 | before { described_class.patch! } 45 | 46 | it "patches #diff to Hash" do 47 | expect({}).to respond_to(:diff) 48 | end 49 | 50 | it "leaves Object alone" do 51 | expect(Object.new).not_to respond_to(:diff) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | 3 | PROJECT_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')).freeze 4 | $LOAD_PATH << File.join(PROJECT_ROOT, 'lib') 5 | Dir[File.join(PROJECT_ROOT, 'spec/support/**/*.rb')].each { |file| require(file) } 6 | 7 | require 'hash_diff' 8 | 9 | RSpec.configure do |c| 10 | c.filter_run_excluding :skip_on_windows => !(RbConfig::CONFIG['host_os'] =~ /mingw32/).nil? 11 | end 12 | --------------------------------------------------------------------------------