diff --git a/Gemfile b/Gemfile index 3506a0e..8357cf1 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,4 @@ gemspec group :test do gem 'rake' end +gem 'rspec' diff --git a/lib/json-diff.rb b/lib/json-diff.rb index f1c10fd..83efeb5 100644 --- a/lib/json-diff.rb +++ b/lib/json-diff.rb @@ -1,4 +1,5 @@ require 'json-diff/diff' +require 'json-diff/v2/diff' require 'json-diff/index-map' require 'json-diff/operation' require 'json-diff/version' diff --git a/lib/json-diff/v2/diff.rb b/lib/json-diff/v2/diff.rb new file mode 100644 index 0000000..927e924 --- /dev/null +++ b/lib/json-diff/v2/diff.rb @@ -0,0 +1,150 @@ +module JsonDiff + class V2 + attr_reader :opts, :changes + + def self.diff(before, after, opts = {}) + runner = new(opts) + runner.diff(before, after) + runner.changes + end + + def initialize(opts = {}) + @opts = opts + @changes = [] + end + + def diff_hash(before, after, path) + lost = before.keys - after.keys + lost.each do |key| + inner_path = JsonDiff.extend_json_pointer(path, key) + changes << JsonDiff.remove(inner_path, include_was(path) ? before[key] : nil) + end + + if include_addition(:hash, path) + gained = after.keys - before.keys + gained.each do |key| + inner_path = JsonDiff.extend_json_pointer(path, key) + changes << JsonDiff.add(inner_path, after[key]) + end + end + + kept = before.keys & after.keys + kept.each do |key| + inner_path = JsonDiff.extend_json_pointer(path, key) + diff(before[key], after[key], inner_path) + end + end + + def diff_array(before, after, path) + if before.size == 0 + if include_addition(:array, path) + after.each_with_index do |item, index| + inner_path = JsonDiff.extend_json_pointer(path, index) + changes << JsonDiff.add(inner_path, item) + end + end + elsif after.size == 0 + before.each do |item| + # Delete elements from the start. + inner_path = JsonDiff.extend_json_pointer(path, 0) + changes << JsonDiff.remove(inner_path, include_was(path) ? item : nil) + end + else + pairing = JsonDiff.array_pairing(before, after, opts) + # FIXME: detect replacements. + + # All detected moves that do not reach the similarity limit are deleted + # and re-added. + pairing[:pairs].select! do |pair| + sim = pair[2] + kept = (sim >= 0.5) + if !kept + pairing[:removed] << pair[0] + pairing[:added] << pair[1] + end + kept + end + + pairing[:pairs].each do |pair| + before_index, after_index = pair + inner_path = JsonDiff.extend_json_pointer(path, before_index) + diff(before[before_index], after[after_index], inner_path) + end + + if !original_indices(path) + # Recompute indices to account for offsets from insertions and + # deletions. + pairing = JsonDiff.array_changes(pairing) + end + + pairing[:removed].each do |before_index| + inner_path = JsonDiff.extend_json_pointer(path, before_index) + changes << JsonDiff.remove(inner_path, include_was(path) ? before[before_index] : nil) + end + + pairing[:pairs].each do |pair| + before_index, after_index = pair + inner_before_path = JsonDiff.extend_json_pointer(path, before_index) + inner_after_path = JsonDiff.extend_json_pointer(path, after_index) + + if before_index != after_index && include_moves(path) + changes << JsonDiff.move(inner_before_path, inner_after_path) + end + end + + if include_addition(:array, path) + pairing[:added].each do |after_index| + inner_path = JsonDiff.extend_json_pointer(path, after_index) + changes << JsonDiff.add(inner_path, after[after_index]) + end + end + end + end + + def diff(before, after, path = '') + if before.is_a?(Hash) + if !after.is_a?(Hash) + changes << JsonDiff.replace(path, include_was(path) ? before : nil, after) if include_replace(path) + else + diff_hash(before, after, path) + end + elsif before.is_a?(Array) + if !after.is_a?(Array) + changes << JsonDiff.replace(path, include_was(path) ? before : nil, after) if include_replace(path) + else + diff_array(before, after, path) + end + else + if before != after + changes << JsonDiff.replace(path, include_was(path) ? before : nil, after) if include_replace(path) + end + end + end + + def include_addition(type, path) + return true if opts[:additions] == nil + opts[:additions].respond_to?(:call) ? opts[:additions].call(type, path) : opts[:additions] + end + + def include_moves(path) + return true if opts[:moves] == nil + opts[:moves].respond_to?(:call) ? opts[:moves].call(path) : opts[:moves] + end + + def include_replace(path) + return true if opts[:replace] == nil + opts[:replace].respond_to?(:call) ? opts[:replace].call(path) : opts[:replace] + end + + def include_was(path) + return false if opts[:include_was] == nil + opts[:include_was].respond_to?(:call) ? opts[:include_was].call(path) : opts[:include_was] + end + + def original_indices(path) + return false if opts[:original_indices] == nil + opts[:original_indices].respond_to?(:call) ? opts[:original_indices].call(path) : opts[:original_indices] + end + end +end + diff --git a/lib/json-diff/version.rb b/lib/json-diff/version.rb index 10e755e..541b44a 100644 --- a/lib/json-diff/version.rb +++ b/lib/json-diff/version.rb @@ -1,3 +1,3 @@ module JsonDiff - VERSION = '0.4.1' + VERSION = '0.5.0-dwhenry' end diff --git a/spec/json-diff/diff_spec.rb b/spec/json-diff/diff_spec.rb index 28ea6f6..f322f77 100644 --- a/spec/json-diff/diff_spec.rb +++ b/spec/json-diff/diff_spec.rb @@ -4,12 +4,12 @@ # Arrays it "should be able to diff two empty arrays" do - diff = JsonDiff.diff([], []) + diff = described_class.diff([], []) expect(diff).to eql([]) end it "should be able to diff an empty array with a filled one" do - diff = JsonDiff.diff([], [1, 2, 3], include_was: true) + diff = described_class.diff([], [1, 2, 3], include_was: true) expect(diff).to eql([ {'op' => 'add', 'path' => "/0", 'value' => 1}, {'op' => 'add', 'path' => "/1", 'value' => 2}, @@ -18,7 +18,7 @@ end it "should be able to diff a filled array with an empty one" do - diff = JsonDiff.diff([1, 2, 3], [], include_was: true) + diff = described_class.diff([1, 2, 3], [], include_was: true) expect(diff).to eql([ {'op' => 'remove', 'path' => "/0", 'was' => 1}, {'op' => 'remove', 'path' => "/0", 'was' => 2}, @@ -27,7 +27,7 @@ end it "should be able to diff a 1-array with a filled one" do - diff = JsonDiff.diff([0], [1, 2, 3], include_was: true) + diff = described_class.diff([0], [1, 2, 3], include_was: true) expect(diff).to eql([ {'op' => 'remove', 'path' => "/0", 'was' => 0}, {'op' => 'add', 'path' => "/0", 'value' => 1}, @@ -37,7 +37,7 @@ end it "should be able to diff a filled array with a 1-array" do - diff = JsonDiff.diff([1, 2, 3], [0], include_was: true) + diff = described_class.diff([1, 2, 3], [0], include_was: true) expect(diff).to eql([ {'op' => 'remove', 'path' => "/2", 'was' => 3}, {'op' => 'remove', 'path' => "/1", 'was' => 2}, @@ -47,7 +47,7 @@ end it "should be able to diff two integer arrays" do - diff = JsonDiff.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], include_was: true) + diff = described_class.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], include_was: true) expect(diff).to eql([ {'op' => 'remove', 'path' => "/4", 'was' => 5}, {'op' => 'remove', 'path' => "/0", 'was' => 1}, @@ -58,12 +58,12 @@ end it "should be able to diff a ring switch" do - diff = JsonDiff.diff([1, 2, 3], [2, 3, 1], include_was: true) + diff = described_class.diff([1, 2, 3], [2, 3, 1], include_was: true) expect(diff).to eql([{"op" => "move", "from" => "/0", "path" => "/2"}]) end it "should be able to diff a ring switch with removals and additions" do - diff = JsonDiff.diff([1, 2, 3, 4], [5, 3, 4, 2], include_was: true) + diff = described_class.diff([1, 2, 3, 4], [5, 3, 4, 2], include_was: true) expect(diff).to eql([ {"op" => "remove", "path" => "/0", "was" => 1}, {"op" => "move", "from" => "/0", "path" => "/2"}, @@ -72,7 +72,7 @@ end it "should be able to diff an array with many additions at its start" do - diff = JsonDiff.diff([0], [1, 2, 3, 0]) + diff = described_class.diff([0], [1, 2, 3, 0]) expect(diff).to eql([ {'op' => 'add', 'path' => "/0", 'value' => 1}, {'op' => 'add', 'path' => "/1", 'value' => 2}, @@ -81,7 +81,7 @@ end it "should be able to diff two arrays with mixed content" do - diff = JsonDiff.diff(["laundry", 12, {'pillar' => 0}, true], [true, {'pillar' => 1}, 3, 12], include_was: true) + diff = described_class.diff(["laundry", 12, {'pillar' => 0}, true], [true, {'pillar' => 1}, 3, 12], include_was: true) expect(diff).to eql([ {'op' => 'replace', 'path' => "/2/pillar", 'was' => 0, 'value' => 1}, {'op' => 'remove', 'path' => "/0", 'was' => "laundry"}, @@ -94,7 +94,7 @@ # Objects it "should be able to diff two objects with mixed content" do - diff = JsonDiff.diff( + diff = described_class.diff( {'string' => "laundry", 'number' => 12, 'object' => {'pillar' => 0}, 'list' => [2, 4, 1], 'bool' => false, 'null' => nil}, {'string' => "laundry", 'number' => 12, 'object' => {'pillar' => 1}, 'list' => [1, 2, 3], 'bool' => true, 'null' => nil}, include_was: true) @@ -110,7 +110,7 @@ # Trans-type it "should be able to diff two objects of mixed type" do - diff = JsonDiff.diff(0, "0", include_was: true) + diff = described_class.diff(0, "0", include_was: true) expect(diff).to eql([ {'op' => 'replace', 'path' => '', 'was' => 0, 'value' => "0"} ]) @@ -119,7 +119,7 @@ # Options it "should be able to diff two integer arrays with original indices" do - diff = JsonDiff.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], original_indices: true) + diff = described_class.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], original_indices: true) expect(diff).to eql([ {'op' => 'remove', 'path' => "/4"}, {'op' => 'remove', 'path' => "/0"}, @@ -130,7 +130,7 @@ end it "should be able to diff two integer arrays without move operations" do - diff = JsonDiff.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], moves: false) + diff = described_class.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], moves: false) expect(diff).to eql([ {'op' => 'remove', 'path' => "/4"}, {'op' => 'remove', 'path' => "/0"}, @@ -139,7 +139,7 @@ end it "should be able to diff two integer arrays without add operations" do - diff = JsonDiff.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], additions: false) + diff = described_class.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], additions: false) expect(diff).to eql([ {'op' => 'remove', 'path' => "/4"}, {'op' => 'remove', 'path' => "/0"}, @@ -159,7 +159,7 @@ end end - diff = JsonDiff.diff([ + diff = described_class.diff([ {id: 1, we: "must", start: "somewhere"}, {id: 2, and: "this", will: "do"}, ], [ diff --git a/spec/json-diff/v2/diff_spec.rb b/spec/json-diff/v2/diff_spec.rb new file mode 100644 index 0000000..6c0440f --- /dev/null +++ b/spec/json-diff/v2/diff_spec.rb @@ -0,0 +1,182 @@ +require 'spec_helper' + +describe JsonDiff::V2 do + # Arrays + + it "should be able to diff two empty arrays" do + diff = described_class.diff([], []) + expect(diff).to eql([]) + end + + it "should be able to diff an empty array with a filled one" do + diff = described_class.diff([], [1, 2, 3], include_was: true) + expect(diff).to eql([ + {'op' => 'add', 'path' => "/0", 'value' => 1}, + {'op' => 'add', 'path' => "/1", 'value' => 2}, + {'op' => 'add', 'path' => "/2", 'value' => 3}, + ]) + end + + it "should be able to diff a filled array with an empty one" do + diff = described_class.diff([1, 2, 3], [], include_was: true) + expect(diff).to eql([ + {'op' => 'remove', 'path' => "/0", 'was' => 1}, + {'op' => 'remove', 'path' => "/0", 'was' => 2}, + {'op' => 'remove', 'path' => "/0", 'was' => 3}, + ]) + end + + it "should be able to diff a 1-array with a filled one" do + diff = described_class.diff([0], [1, 2, 3], include_was: true) + expect(diff).to eql([ + {'op' => 'remove', 'path' => "/0", 'was' => 0}, + {'op' => 'add', 'path' => "/0", 'value' => 1}, + {'op' => 'add', 'path' => "/1", 'value' => 2}, + {'op' => 'add', 'path' => "/2", 'value' => 3}, + ]) + end + + it "should be able to diff a filled array with a 1-array" do + diff = described_class.diff([1, 2, 3], [0], include_was: true) + expect(diff).to eql([ + {'op' => 'remove', 'path' => "/2", 'was' => 3}, + {'op' => 'remove', 'path' => "/1", 'was' => 2}, + {'op' => 'remove', 'path' => "/0", 'was' => 1}, + {'op' => 'add', 'path' => "/0", 'value' => 0}, + ]) + end + + it "should be able to diff two integer arrays" do + diff = described_class.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], include_was: true) + expect(diff).to eql([ + {'op' => 'remove', 'path' => "/4", 'was' => 5}, + {'op' => 'remove', 'path' => "/0", 'was' => 1}, + {'op' => 'move', 'from' => "/0", 'path' => "/2"}, + {'op' => 'move', 'from' => "/1", 'path' => "/0"}, + {'op' => 'add', 'path' => "/0", 'value' => 6}, + ]) + end + + it "should be able to diff a ring switch" do + diff = described_class.diff([1, 2, 3], [2, 3, 1], include_was: true) + expect(diff).to eql([{"op" => "move", "from" => "/0", "path" => "/2"}]) + end + + it "should be able to diff a ring switch with removals and additions" do + diff = described_class.diff([1, 2, 3, 4], [5, 3, 4, 2], include_was: true) + expect(diff).to eql([ + {"op" => "remove", "path" => "/0", "was" => 1}, + {"op" => "move", "from" => "/0", "path" => "/2"}, + {"op" => "add", "path" => "/0", "value" => 5}, + ]) + end + + it "should be able to diff an array with many additions at its start" do + diff = described_class.diff([0], [1, 2, 3, 0]) + expect(diff).to eql([ + {'op' => 'add', 'path' => "/0", 'value' => 1}, + {'op' => 'add', 'path' => "/1", 'value' => 2}, + {'op' => 'add', 'path' => "/2", 'value' => 3}, + ]) + end + + it "should be able to diff two arrays with mixed content" do + diff = described_class.diff(["laundry", 12, {'pillar' => 0}, true], [true, {'pillar' => 1}, 3, 12], include_was: true) + expect(diff).to eql([ + {'op' => 'replace', 'path' => "/2/pillar", 'was' => 0, 'value' => 1}, + {'op' => 'remove', 'path' => "/0", 'was' => "laundry"}, + {'op' => 'move', 'from' => "/0", 'path' => "/2"}, + {'op' => 'move', 'from' => "/1", 'path' => "/0"}, + {'op' => 'add', 'path' => "/2", 'value' => 3}, + ]) + end + + # Objects + + it "should be able to diff two objects with mixed content" do + diff = described_class.diff( + {'string' => "laundry", 'number' => 12, 'object' => {'pillar' => 0}, 'list' => [2, 4, 1], 'bool' => false, 'null' => nil}, + {'string' => "laundry", 'number' => 12, 'object' => {'pillar' => 1}, 'list' => [1, 2, 3], 'bool' => true, 'null' => nil}, + include_was: true) + expect(diff).to eql([ + {'op' => 'replace', 'path' => "/object/pillar", 'was' => 0, 'value' => 1}, + {'op' => 'remove', 'path' => "/list/1", 'was' => 4}, + {'op' => 'move', 'from' => "/list/0", 'path' => "/list/1"}, + {'op' => 'add', 'path' => "/list/2", 'value' => 3}, + {'op' => 'replace', 'path' => "/bool", 'was' => false, 'value' => true}, + ]) + end + + # Trans-type + + it "should be able to diff two objects of mixed type" do + diff = described_class.diff(0, "0", include_was: true) + expect(diff).to eql([ + {'op' => 'replace', 'path' => '', 'was' => 0, 'value' => "0"} + ]) + end + + # Options + + it "should be able to diff two integer arrays with original indices" do + diff = described_class.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], original_indices: true) + expect(diff).to eql([ + {'op' => 'remove', 'path' => "/4"}, + {'op' => 'remove', 'path' => "/0"}, + {'op' => 'move', 'from' => "/1", 'path' => "/3"}, + {'op' => 'move', 'from' => "/3", 'path' => "/1"}, + {'op' => 'add', 'path' => "/0", 'value' => 6}, + ]) + end + + it "should be able to diff two integer arrays without move operations" do + diff = described_class.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], moves: false) + expect(diff).to eql([ + {'op' => 'remove', 'path' => "/4"}, + {'op' => 'remove', 'path' => "/0"}, + {'op' => 'add', 'path' => "/0", 'value' => 6}, + ]) + end + + it "should be able to diff two integer arrays without add operations" do + diff = described_class.diff([1, 2, 3, 4, 5], [6, 4, 3, 2], additions: false) + expect(diff).to eql([ + {'op' => 'remove', 'path' => "/4"}, + {'op' => 'remove', 'path' => "/0"}, + {'op' => 'move', 'from' => "/0", 'path' => "/2"}, + {'op' => 'move', 'from' => "/1", 'path' => "/0"}, + ]) + end + + it "should be able to diff two objects with a custom similarity" do + similarity = -> (before, after) do + if before.is_a?(Hash) && after.is_a?(Hash) + if before[:id] == after[:id] + 1.0 + else + 0.0 + end + end + end + + diff = described_class.diff([ + {id: 1, we: "must", start: "somewhere"}, + {id: 2, and: "this", will: "do"}, + ], [ + {id: 2, insert: "something", completely: "different"}, + {id: 1, this: "too", is: "different"}, + ], similarity: similarity) + expect(diff).to eql([ + {'op' => 'remove', 'path' => '/0/we'}, + {'op' => 'remove', 'path' => '/0/start'}, + {'op' => 'add', 'path' => '/0/this', 'value' => 'too'}, + {'op' => 'add', 'path' => '/0/is', 'value' => 'different'}, + {'op' => 'remove', 'path' => '/1/and'}, + {'op' => 'remove', 'path' => '/1/will'}, + {'op' => 'add', 'path' => '/1/insert', 'value' => 'something'}, + {'op' => 'add', 'path' => '/1/completely', 'value' => 'different'}, + {'op' => 'move', 'from' => '/0', 'path' => '/1'}, + ]) + end + +end