From ca6b53f08743f0c7f3a3ae63f72820ad09ed60a1 Mon Sep 17 00:00:00 2001 From: David Henry Date: Fri, 31 Jul 2020 10:04:41 +0100 Subject: [PATCH 1/5] Full copy of code to v2 namespace --- lib/json-diff/v2/diff.rb | 348 ++++++++++++++++++++++++++++++++++ lib/json-diff/v2/index-map.rb | 50 +++++ lib/json-diff/v2/operation.rb | 53 ++++++ lib/json-diff/v2/version.rb | 3 + 4 files changed, 454 insertions(+) create mode 100644 lib/json-diff/v2/diff.rb create mode 100644 lib/json-diff/v2/index-map.rb create mode 100644 lib/json-diff/v2/operation.rb create mode 100644 lib/json-diff/v2/version.rb diff --git a/lib/json-diff/v2/diff.rb b/lib/json-diff/v2/diff.rb new file mode 100644 index 0000000..b3af83f --- /dev/null +++ b/lib/json-diff/v2/diff.rb @@ -0,0 +1,348 @@ +module JsonDiff + class V2 + attr_reader :opts + def initialize(opts = {}) + @opts = opts + end + + def diff(before, after, path = '') + + end + + def include_addition + (opts[:additions] == nil) ? true : opts[:additions] + end + + def include_moves + (opts[:moves] == nil) ? true : opts[:moves] + end + + def include_was + (opts[:include_was] == nil) ? false : opts[:include_was] + end + + def original_indices + (opts[:original_indices] == nil) ? false : opts[:original_indices] + end + + end + def self.diff(before, after, opts = {}) + + changes = [] + + if before.is_a?(Hash) + if !after.is_a?(Hash) + changes << replace(path, include_was ? before : nil, after) + else + lost = before.keys - after.keys + lost.each do |key| + inner_path = extend_json_pointer(path, key) + changes << remove(inner_path, include_was ? before[key] : nil) + end + + if include_addition + gained = after.keys - before.keys + gained.each do |key| + inner_path = extend_json_pointer(path, key) + changes << add(inner_path, after[key]) + end + end + + kept = before.keys & after.keys + kept.each do |key| + inner_path = extend_json_pointer(path, key) + changes += diff(before[key], after[key], opts.merge(path: inner_path)) + end + end + elsif before.is_a?(Array) + if !after.is_a?(Array) + changes << replace(path, include_was ? before : nil, after) + elsif before.size == 0 + if include_addition + after.each_with_index do |item, index| + inner_path = extend_json_pointer(path, index) + changes << add(inner_path, item) + end + end + elsif after.size == 0 + before.each do |item| + # Delete elements from the start. + inner_path = extend_json_pointer(path, 0) + changes << remove(inner_path, include_was ? item : nil) + end + else + pairing = 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 = extend_json_pointer(path, before_index) + changes += diff(before[before_index], after[after_index], opts.merge(path: inner_path)) + end + + if !original_indices + # Recompute indices to account for offsets from insertions and + # deletions. + pairing = array_changes(pairing) + end + + pairing[:removed].each do |before_index| + inner_path = extend_json_pointer(path, before_index) + changes << remove(inner_path, include_was ? before[before_index] : nil) + end + + pairing[:pairs].each do |pair| + before_index, after_index = pair + inner_before_path = extend_json_pointer(path, before_index) + inner_after_path = extend_json_pointer(path, after_index) + + if before_index != after_index && include_moves + changes << move(inner_before_path, inner_after_path) + end + end + + if include_addition + pairing[:added].each do |after_index| + inner_path = extend_json_pointer(path, after_index) + changes << add(inner_path, after[after_index]) + end + end + end + else + if before != after + changes << replace(path, include_was ? before : nil, after) + end + end + + changes + end + + # {pairs: [[before index, after index, similarity]], + # removed: [before index], + # added: [after index]} + # + # - options[:similarity]: procedure taking (before, after) objects. + # Returns a probability between 0 and 1 of how likely `after` is a + # modification of `before`, or nil if it cannot determine it. + def self.array_pairing(before, after, options) + # Array containing the array of similarities from before to after. + similarities = before.map do |before_item| + after.map do |after_item| + similarity(before_item, after_item, options) + end + end + + # Array containing the array of couples of indices, sorted by similarity. + indices = before.map.with_index do |before_item, before_index| + after.map.with_index do |after_item, after_index| + [before_index, after_index] + end + end + + # Sort them in O(n^2 log(n)). + indices.map! do |couples| + couples.sort! do |a, b| + a_before_index = a[0] + b_before_index = b[0] + a_after_index = a[1] + b_after_index = b[1] + + similarities[b_before_index][b_after_index] <=> similarities[a_before_index][a_after_index] + end + end + # Sort the toplevel. + indices.sort! do |a, b| + a_top_before_index = a[0][0] + a_top_after_index = a[0][1] + b_top_before_index = b[0][0] + b_top_after_index = b[0][1] + + similarities[b_top_before_index][b_top_after_index] <=> similarities[a_top_before_index][a_top_after_index] + end + + # Map from indices to boolean (true if paired). + before_paired = {} + after_paired = {} + + num_pairs = [before.size, after.size].min + + pairs = (0...num_pairs).map do |_| + unpaired_before_index = indices.index { |a| !before_paired[a[0][0]] } + unpaired_after_index = indices[unpaired_before_index].index { |a| !after_paired[a[1]] } + unpaired_couple = indices[unpaired_before_index][unpaired_after_index] + before_paired[unpaired_couple[0]] = true + after_paired[unpaired_couple[1]] = true + + [unpaired_couple[0], unpaired_couple[1], + similarities[unpaired_couple[0]][unpaired_couple[1]]] + end + + if before.size < after.size + added = after.map.with_index { |_, i| i} - after_paired.keys + removed = [] + else + removed = before.map.with_index { |_, i| i } - before_paired.keys + added = [] + end + + { + pairs: pairs, + removed: removed, + added: added, + } + end + + # Compute an arbitrary notion of how probable it is that one object is the + # result of modifying the other. + # + # - options[:similarity]: procedure taking (before, after) objects. + # Returns a probability between 0 and 1 of how likely `after` is a + # modification of `before`, or nil if it cannot determine it. + def self.similarity(before, after, options) + return 0.0 if before.class != after.class + + # Use the custom similarity procedure if it isn't nil. + if options[:similarity] != nil + custom_result = options[:similarity].call(before, after) + return custom_result if custom_result != nil + end + + if before.is_a?(Hash) + if before.size == 0 + if after.size == 0 + return 1.0 + else + return 0.0 + end + end + + # Average similarity between keys' value. + # We don't consider key renames. + similarities = [] + before.each do |before_key, before_item| + similarities << similarity(before_item, after[before_key], options) + end + # Also consider keys' names. + before_keys = before.keys + after_keys = after.keys + key_similarity = (before_keys & after_keys).size / (before_keys | after_keys).size + similarities << key_similarity + + similarities.reduce(:+) / similarities.size + elsif before.is_a?(Array) + return 1.0 if before.size == 0 + + # The most likely match between an element in the old and the new list is + # presumably the right one, so we take the average of the maximum + # similarity between each elements of the list. + similarities = before.map do |before_item| + after.map do |after_item| + similarity(before_item, after_item, options) + end.max || 0.0 + end + + similarities.reduce(:+) / similarities.size + elsif before == after + 1.0 + else + 0.0 + end + end + + # Input: + # {pairs: [[before index, after index, similarity]], + # removed: [before index], + # added: [after index]} + # + # Output: + # {removed: [before index], + # pairs: [[before index, after index, + # original before index, original after index]], + # added: [after index]} + def self.array_changes(pairing) + # We perform removals starting from the highest index. + # That way, they don't offset their own. + pairing[:removed].sort!.reverse! + pairing[:added].sort! + + # First, map indices from before to after removals. + removal_map = IndexMaps.new + pairing[:removed].each { |rm| removal_map.removal(rm) } + # And map indices from after to before additions + # (removals, since it is reversed). + addition_map = IndexMaps.new + pairing[:added].reverse.each { |ad| addition_map.removal(ad) } + + moves = {} + orig_before = {} + orig_after = {} + pairing[:pairs].each do |before, after| + mapped_before = removal_map.map(before) + mapped_after = addition_map.map(after) + orig_before[mapped_before] = before + orig_after[mapped_after] = after + moves[mapped_before] = mapped_after + end + + # Now, detect rings within the pairs. + # The proof is, if whatever was at position i was sent to position j, + # whatever was at position j cannot have stayed at j. + # By induction, there is a ring. + # Oh, and a piece of the proof is that the arrays have the same length. + # Trivially. Right. Hey, this is not an interview! + rings = [] + while moves.size > 0 + # i goes to j. j goes to (…). k goes to i. + ring = [] + pair = moves.shift + origin, target = pair + first_origin = origin + while target != first_origin + ring << origin + origin = target + target = moves[target] + moves.delete(origin) + end + ring << origin + rings << ring + end + # rings is of the form [[i,j,k], …] + + # Finally, we can register the moves. + # The idea is, if the whole ring moves instantaneously, + # no element outside of the ring changed position. + pairs = [] + rings.each do |ring| + orig_ring = ring.map { |i| [orig_before[i], orig_after[i]] } + ring_map = IndexMaps.new + len = ring.size + i = 0 + while i < len + ni = (i + 1) % len # next i + if ring[i] != ring[ni] + pairs << [ring_map.map(ring[i]), ring[ni], orig_ring[i][0], orig_ring[ni][1]] + end + ring_map.removal(ring[i]) + ring_map.addition(ring[ni]) + i += 1 + end + end + + pairing[:pairs] = pairs + + pairing + end + +end diff --git a/lib/json-diff/v2/index-map.rb b/lib/json-diff/v2/index-map.rb new file mode 100644 index 0000000..311ca3c --- /dev/null +++ b/lib/json-diff/v2/index-map.rb @@ -0,0 +1,50 @@ +module JsonDiff + + class IndexMaps + def initialize + @maps = [] + end + + def addition(index) + @maps << AdditionIndexMap.new(index) + end + + def removal(index) + @maps << RemovalIndexMap.new(index) + end + + def map(index) + @maps.each do |map| + index = map.map(index) + end + index + end + end + + class IndexMap + def initialize(pivot) + @pivot = pivot + end + end + + class AdditionIndexMap < IndexMap + def map(index) + if index >= @pivot + index + 1 + else + index + end + end + end + + class RemovalIndexMap < IndexMap + def map(index) + if index >= @pivot + index - 1 + else + index + end + end + end + +end diff --git a/lib/json-diff/v2/operation.rb b/lib/json-diff/v2/operation.rb new file mode 100644 index 0000000..1c2693e --- /dev/null +++ b/lib/json-diff/v2/operation.rb @@ -0,0 +1,53 @@ +module JsonDiff + class v2 + # Convert a list of strings or numbers to an RFC6901 JSON pointer. + # http://tools.ietf.org/html/rfc6901 + def json_pointer(path) + return "" if path == [] + + escaped_path = path.map do |key| + if key.is_a?(String) + key.gsub('~', '~0') + .gsub('/', '~1') + else + key.to_s + end + end.join('/') + + "/#{escaped_path}" + end + + # Add a key to a JSON pointer. + def extend_json_pointer(pointer, key) + if pointer == '' + json_pointer([key]) + else + pointer + json_pointer([key]) + end + end + + def replace(path, before, after) + if before != nil + {'op' => 'replace', 'path' => path, 'was' => before, 'value' => after} + else + {'op' => 'replace', 'path' => path, 'value' => after} + end + end + + def add(path, value) + {'op' => 'add', 'path' => path, 'value' => value} + end + + def remove(path, value) + if value != nil + {'op' => 'remove', 'path' => path, 'was' => value} + else + {'op' => 'remove', 'path' => path} + end + end + + def move(source, target) + {'op' => 'move', 'from' => source, 'path' => target} + end + end +end diff --git a/lib/json-diff/v2/version.rb b/lib/json-diff/v2/version.rb new file mode 100644 index 0000000..10e755e --- /dev/null +++ b/lib/json-diff/v2/version.rb @@ -0,0 +1,3 @@ +module JsonDiff + VERSION = '0.4.1' +end From 1fda216cd63ea6c556b2ee9b16bbd56a1f2c1ed6 Mon Sep 17 00:00:00 2001 From: David Henry Date: Fri, 31 Jul 2020 10:05:22 +0100 Subject: [PATCH 2/5] Break diff alogorithm up into parts to make it easier to process --- lib/json-diff/v2/diff.rb | 119 ++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 57 deletions(-) diff --git a/lib/json-diff/v2/diff.rb b/lib/json-diff/v2/diff.rb index b3af83f..7d130e9 100644 --- a/lib/json-diff/v2/diff.rb +++ b/lib/json-diff/v2/diff.rb @@ -1,63 +1,36 @@ module JsonDiff class V2 - attr_reader :opts + attr_reader :opts, :changes + def initialize(opts = {}) @opts = opts + @change = [] end - def diff(before, after, path = '') - - end - - def include_addition - (opts[:additions] == nil) ? true : opts[:additions] - end - - def include_moves - (opts[:moves] == nil) ? true : opts[:moves] - end - - def include_was - (opts[:include_was] == nil) ? false : opts[:include_was] - end - - def original_indices - (opts[:original_indices] == nil) ? false : opts[:original_indices] - end - - end - def self.diff(before, after, opts = {}) - - changes = [] + def diff_hash(before, after, path) + lost = before.keys - after.keys + lost.each do |key| + inner_path = extend_json_pointer(path, key) + changes << remove(inner_path, include_was ? before[key] : nil) + end - if before.is_a?(Hash) - if !after.is_a?(Hash) - changes << replace(path, include_was ? before : nil, after) - else - lost = before.keys - after.keys - lost.each do |key| + if include_addition + gained = after.keys - before.keys + gained.each do |key| inner_path = extend_json_pointer(path, key) - changes << remove(inner_path, include_was ? before[key] : nil) - end - - if include_addition - gained = after.keys - before.keys - gained.each do |key| - inner_path = extend_json_pointer(path, key) - changes << add(inner_path, after[key]) - end + changes << add(inner_path, after[key]) end + end - kept = before.keys & after.keys - kept.each do |key| - inner_path = extend_json_pointer(path, key) - changes += diff(before[key], after[key], opts.merge(path: inner_path)) - end + kept = before.keys & after.keys + kept.each do |key| + inner_path = extend_json_pointer(path, key) + diff(before[key], after[key], inner_path) end - elsif before.is_a?(Array) - if !after.is_a?(Array) - changes << replace(path, include_was ? before : nil, after) - elsif before.size == 0 + end + + def diff_array(before, after, path) + if before.size == 0 if include_addition after.each_with_index do |item, index| inner_path = extend_json_pointer(path, index) @@ -89,7 +62,7 @@ def self.diff(before, after, opts = {}) pairing[:pairs].each do |pair| before_index, after_index = pair inner_path = extend_json_pointer(path, before_index) - changes += diff(before[before_index], after[after_index], opts.merge(path: inner_path)) + diff(before[before_index], after[after_index], inner_path) end if !original_indices @@ -120,13 +93,45 @@ def self.diff(before, after, opts = {}) end end end - else - if before != after - changes << replace(path, include_was ? before : nil, after) + end + + def diff(before, after, path = '') + if before.is_a?(Hash) + if !after.is_a?(Hash) + changes << replace(path, include_was ? before : nil, after) + else + diff_hash(before, after, path) + end + elsif before.is_a?(Array) + if !after.is_a?(Array) + changes << replace(path, include_was ? before : nil, after) + else + diff_array(before, after, path) + end + else + if before != after + changes << replace(path, include_was ? before : nil, after) + end end + + end + + def include_addition + (opts[:additions] == nil) ? true : opts[:additions] + end + + def include_moves + (opts[:moves] == nil) ? true : opts[:moves] + end + + def include_was + (opts[:include_was] == nil) ? false : opts[:include_was] + end + + def original_indices + (opts[:original_indices] == nil) ? false : opts[:original_indices] end - changes end # {pairs: [[before index, after index, similarity]], @@ -136,7 +141,7 @@ def self.diff(before, after, opts = {}) # - options[:similarity]: procedure taking (before, after) objects. # Returns a probability between 0 and 1 of how likely `after` is a # modification of `before`, or nil if it cannot determine it. - def self.array_pairing(before, after, options) + def array_pairing(before, after, options) # Array containing the array of similarities from before to after. similarities = before.map do |before_item| after.map do |after_item| @@ -210,7 +215,7 @@ def self.array_pairing(before, after, options) # - options[:similarity]: procedure taking (before, after) objects. # Returns a probability between 0 and 1 of how likely `after` is a # modification of `before`, or nil if it cannot determine it. - def self.similarity(before, after, options) + def similarity(before, after, options) return 0.0 if before.class != after.class # Use the custom similarity procedure if it isn't nil. @@ -271,7 +276,7 @@ def self.similarity(before, after, options) # pairs: [[before index, after index, # original before index, original after index]], # added: [after index]} - def self.array_changes(pairing) + def array_changes(pairing) # We perform removals starting from the highest index. # That way, they don't offset their own. pairing[:removed].sort!.reverse! From 43b7d14baf135bacf86c711bd2363769aeb87b6f Mon Sep 17 00:00:00 2001 From: David Henry Date: Fri, 31 Jul 2020 10:07:13 +0100 Subject: [PATCH 3/5] Allow options to be passed in procs with instead of constants with path awareness --- lib/json-diff/v2/diff.rb | 44 +++++++++++++++--------------- lib/json-diff/v2/index-map.rb | 50 ----------------------------------- lib/json-diff/v2/version.rb | 3 --- 3 files changed, 23 insertions(+), 74 deletions(-) delete mode 100644 lib/json-diff/v2/index-map.rb delete mode 100644 lib/json-diff/v2/version.rb diff --git a/lib/json-diff/v2/diff.rb b/lib/json-diff/v2/diff.rb index 7d130e9..d75f7b0 100644 --- a/lib/json-diff/v2/diff.rb +++ b/lib/json-diff/v2/diff.rb @@ -11,10 +11,10 @@ def diff_hash(before, after, path) lost = before.keys - after.keys lost.each do |key| inner_path = extend_json_pointer(path, key) - changes << remove(inner_path, include_was ? before[key] : nil) + changes << remove(inner_path, include_was(path) ? before[key] : nil) end - if include_addition + if include_addition(:hash, path) gained = after.keys - before.keys gained.each do |key| inner_path = extend_json_pointer(path, key) @@ -31,7 +31,7 @@ def diff_hash(before, after, path) def diff_array(before, after, path) if before.size == 0 - if include_addition + if include_addition(:array, path) after.each_with_index do |item, index| inner_path = extend_json_pointer(path, index) changes << add(inner_path, item) @@ -41,7 +41,7 @@ def diff_array(before, after, path) before.each do |item| # Delete elements from the start. inner_path = extend_json_pointer(path, 0) - changes << remove(inner_path, include_was ? item : nil) + changes << remove(inner_path, include_was(path) ? item : nil) end else pairing = array_pairing(before, after, opts) @@ -65,7 +65,7 @@ def diff_array(before, after, path) diff(before[before_index], after[after_index], inner_path) end - if !original_indices + if !original_indices(path) # Recompute indices to account for offsets from insertions and # deletions. pairing = array_changes(pairing) @@ -73,7 +73,7 @@ def diff_array(before, after, path) pairing[:removed].each do |before_index| inner_path = extend_json_pointer(path, before_index) - changes << remove(inner_path, include_was ? before[before_index] : nil) + changes << remove(inner_path, include_was(path) ? before[before_index] : nil) end pairing[:pairs].each do |pair| @@ -81,12 +81,12 @@ def diff_array(before, after, path) inner_before_path = extend_json_pointer(path, before_index) inner_after_path = extend_json_pointer(path, after_index) - if before_index != after_index && include_moves + if before_index != after_index && include_moves(path) changes << move(inner_before_path, inner_after_path) end end - if include_addition + if include_addition(:array, path) pairing[:added].each do |after_index| inner_path = extend_json_pointer(path, after_index) changes << add(inner_path, after[after_index]) @@ -98,40 +98,42 @@ def diff_array(before, after, path) def diff(before, after, path = '') if before.is_a?(Hash) if !after.is_a?(Hash) - changes << replace(path, include_was ? before : nil, after) + changes << replace(path, include_was(path) ? before : nil, after) else diff_hash(before, after, path) end elsif before.is_a?(Array) if !after.is_a?(Array) - changes << replace(path, include_was ? before : nil, after) + changes << replace(path, include_was(path) ? before : nil, after) else diff_array(before, after, path) end else if before != after - changes << replace(path, include_was ? before : nil, after) + changes << replace(path, include_was(path) ? before : nil, after) end end - end - def include_addition - (opts[:additions] == nil) ? true : opts[:additions] + 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 - (opts[:moves] == nil) ? true : opts[:moves] + def include_moves(path) + return true if opts[:moves] == nil + opts[:moves].respond_to?(:call) ? opts[:moves].call(path) : opts[:moves] end - def include_was - (opts[:include_was] == nil) ? false : opts[:include_was] + def include_was(path) + return true if opts[:include_was] == nil + opts[:include_was].respond_to?(:call) ? opts[:include_was].call(path) : opts[:include_was] end - def original_indices - (opts[:original_indices] == nil) ? false : opts[:original_indices] + def original_indices(path) + return true if opts[:original_indices] == nil + opts[:original_indices].respond_to?(:call) ? opts[:original_indices].call(path) : opts[:original_indices] end - end # {pairs: [[before index, after index, similarity]], diff --git a/lib/json-diff/v2/index-map.rb b/lib/json-diff/v2/index-map.rb deleted file mode 100644 index 311ca3c..0000000 --- a/lib/json-diff/v2/index-map.rb +++ /dev/null @@ -1,50 +0,0 @@ -module JsonDiff - - class IndexMaps - def initialize - @maps = [] - end - - def addition(index) - @maps << AdditionIndexMap.new(index) - end - - def removal(index) - @maps << RemovalIndexMap.new(index) - end - - def map(index) - @maps.each do |map| - index = map.map(index) - end - index - end - end - - class IndexMap - def initialize(pivot) - @pivot = pivot - end - end - - class AdditionIndexMap < IndexMap - def map(index) - if index >= @pivot - index + 1 - else - index - end - end - end - - class RemovalIndexMap < IndexMap - def map(index) - if index >= @pivot - index - 1 - else - index - end - end - end - -end diff --git a/lib/json-diff/v2/version.rb b/lib/json-diff/v2/version.rb deleted file mode 100644 index 10e755e..0000000 --- a/lib/json-diff/v2/version.rb +++ /dev/null @@ -1,3 +0,0 @@ -module JsonDiff - VERSION = '0.4.1' -end From 6dee42dca849f37ebea1d7e4f2e7e2c857d6ed72 Mon Sep 17 00:00:00 2001 From: David Henry Date: Fri, 31 Jul 2020 10:24:48 +0100 Subject: [PATCH 4/5] Reuse V1 code whenever possible and add tests --- Gemfile | 1 + lib/json-diff.rb | 1 + lib/json-diff/v2/diff.rb | 273 ++++----------------------------- lib/json-diff/v2/operation.rb | 53 ------- lib/json-diff/version.rb | 2 +- spec/json-diff/diff_spec.rb | 32 ++-- spec/json-diff/v2/diff_spec.rb | 182 ++++++++++++++++++++++ 7 files changed, 232 insertions(+), 312 deletions(-) delete mode 100644 lib/json-diff/v2/operation.rb create mode 100644 spec/json-diff/v2/diff_spec.rb 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 index d75f7b0..a8c6975 100644 --- a/lib/json-diff/v2/diff.rb +++ b/lib/json-diff/v2/diff.rb @@ -2,29 +2,35 @@ 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 - @change = [] + @changes = [] end def diff_hash(before, after, path) lost = before.keys - after.keys lost.each do |key| - inner_path = extend_json_pointer(path, key) - changes << remove(inner_path, include_was(path) ? before[key] : nil) + 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 = extend_json_pointer(path, key) - changes << add(inner_path, after[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 = extend_json_pointer(path, key) + inner_path = JsonDiff.extend_json_pointer(path, key) diff(before[key], after[key], inner_path) end end @@ -33,18 +39,18 @@ def diff_array(before, after, path) if before.size == 0 if include_addition(:array, path) after.each_with_index do |item, index| - inner_path = extend_json_pointer(path, index) - changes << add(inner_path, item) + 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 = extend_json_pointer(path, 0) - changes << remove(inner_path, include_was(path) ? item : nil) + inner_path = JsonDiff.extend_json_pointer(path, 0) + changes << JsonDiff.remove(inner_path, include_was(path) ? item : nil) end else - pairing = array_pairing(before, after, opts) + pairing = JsonDiff.array_pairing(before, after, opts) # FIXME: detect replacements. # All detected moves that do not reach the similarity limit are deleted @@ -61,35 +67,35 @@ def diff_array(before, after, path) pairing[:pairs].each do |pair| before_index, after_index = pair - inner_path = extend_json_pointer(path, before_index) + 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 = array_changes(pairing) + pairing = JsonDiff.array_changes(pairing) end pairing[:removed].each do |before_index| - inner_path = extend_json_pointer(path, before_index) - changes << remove(inner_path, include_was(path) ? before[before_index] : nil) + 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 = extend_json_pointer(path, before_index) - inner_after_path = extend_json_pointer(path, after_index) + 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 << move(inner_before_path, inner_after_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 = extend_json_pointer(path, after_index) - changes << add(inner_path, after[after_index]) + inner_path = JsonDiff.extend_json_pointer(path, after_index) + changes << JsonDiff.add(inner_path, after[after_index]) end end end @@ -98,19 +104,19 @@ def diff_array(before, after, path) def diff(before, after, path = '') if before.is_a?(Hash) if !after.is_a?(Hash) - changes << replace(path, include_was(path) ? before : nil, after) + changes << JsonDiff.replace(path, include_was(path) ? before : nil, after) else diff_hash(before, after, path) end elsif before.is_a?(Array) if !after.is_a?(Array) - changes << replace(path, include_was(path) ? before : nil, after) + changes << JsonDiff.replace(path, include_was(path) ? before : nil, after) else diff_array(before, after, path) end else if before != after - changes << replace(path, include_was(path) ? before : nil, after) + changes << JsonDiff.replace(path, include_was(path) ? before : nil, after) end end end @@ -126,230 +132,13 @@ def include_moves(path) end def include_was(path) - return true if opts[:include_was] == nil + 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 true if opts[:original_indices] == nil + return false if opts[:original_indices] == nil opts[:original_indices].respond_to?(:call) ? opts[:original_indices].call(path) : opts[:original_indices] end end - - # {pairs: [[before index, after index, similarity]], - # removed: [before index], - # added: [after index]} - # - # - options[:similarity]: procedure taking (before, after) objects. - # Returns a probability between 0 and 1 of how likely `after` is a - # modification of `before`, or nil if it cannot determine it. - def array_pairing(before, after, options) - # Array containing the array of similarities from before to after. - similarities = before.map do |before_item| - after.map do |after_item| - similarity(before_item, after_item, options) - end - end - - # Array containing the array of couples of indices, sorted by similarity. - indices = before.map.with_index do |before_item, before_index| - after.map.with_index do |after_item, after_index| - [before_index, after_index] - end - end - - # Sort them in O(n^2 log(n)). - indices.map! do |couples| - couples.sort! do |a, b| - a_before_index = a[0] - b_before_index = b[0] - a_after_index = a[1] - b_after_index = b[1] - - similarities[b_before_index][b_after_index] <=> similarities[a_before_index][a_after_index] - end - end - # Sort the toplevel. - indices.sort! do |a, b| - a_top_before_index = a[0][0] - a_top_after_index = a[0][1] - b_top_before_index = b[0][0] - b_top_after_index = b[0][1] - - similarities[b_top_before_index][b_top_after_index] <=> similarities[a_top_before_index][a_top_after_index] - end - - # Map from indices to boolean (true if paired). - before_paired = {} - after_paired = {} - - num_pairs = [before.size, after.size].min - - pairs = (0...num_pairs).map do |_| - unpaired_before_index = indices.index { |a| !before_paired[a[0][0]] } - unpaired_after_index = indices[unpaired_before_index].index { |a| !after_paired[a[1]] } - unpaired_couple = indices[unpaired_before_index][unpaired_after_index] - before_paired[unpaired_couple[0]] = true - after_paired[unpaired_couple[1]] = true - - [unpaired_couple[0], unpaired_couple[1], - similarities[unpaired_couple[0]][unpaired_couple[1]]] - end - - if before.size < after.size - added = after.map.with_index { |_, i| i} - after_paired.keys - removed = [] - else - removed = before.map.with_index { |_, i| i } - before_paired.keys - added = [] - end - - { - pairs: pairs, - removed: removed, - added: added, - } - end - - # Compute an arbitrary notion of how probable it is that one object is the - # result of modifying the other. - # - # - options[:similarity]: procedure taking (before, after) objects. - # Returns a probability between 0 and 1 of how likely `after` is a - # modification of `before`, or nil if it cannot determine it. - def similarity(before, after, options) - return 0.0 if before.class != after.class - - # Use the custom similarity procedure if it isn't nil. - if options[:similarity] != nil - custom_result = options[:similarity].call(before, after) - return custom_result if custom_result != nil - end - - if before.is_a?(Hash) - if before.size == 0 - if after.size == 0 - return 1.0 - else - return 0.0 - end - end - - # Average similarity between keys' value. - # We don't consider key renames. - similarities = [] - before.each do |before_key, before_item| - similarities << similarity(before_item, after[before_key], options) - end - # Also consider keys' names. - before_keys = before.keys - after_keys = after.keys - key_similarity = (before_keys & after_keys).size / (before_keys | after_keys).size - similarities << key_similarity - - similarities.reduce(:+) / similarities.size - elsif before.is_a?(Array) - return 1.0 if before.size == 0 - - # The most likely match between an element in the old and the new list is - # presumably the right one, so we take the average of the maximum - # similarity between each elements of the list. - similarities = before.map do |before_item| - after.map do |after_item| - similarity(before_item, after_item, options) - end.max || 0.0 - end - - similarities.reduce(:+) / similarities.size - elsif before == after - 1.0 - else - 0.0 - end - end - - # Input: - # {pairs: [[before index, after index, similarity]], - # removed: [before index], - # added: [after index]} - # - # Output: - # {removed: [before index], - # pairs: [[before index, after index, - # original before index, original after index]], - # added: [after index]} - def array_changes(pairing) - # We perform removals starting from the highest index. - # That way, they don't offset their own. - pairing[:removed].sort!.reverse! - pairing[:added].sort! - - # First, map indices from before to after removals. - removal_map = IndexMaps.new - pairing[:removed].each { |rm| removal_map.removal(rm) } - # And map indices from after to before additions - # (removals, since it is reversed). - addition_map = IndexMaps.new - pairing[:added].reverse.each { |ad| addition_map.removal(ad) } - - moves = {} - orig_before = {} - orig_after = {} - pairing[:pairs].each do |before, after| - mapped_before = removal_map.map(before) - mapped_after = addition_map.map(after) - orig_before[mapped_before] = before - orig_after[mapped_after] = after - moves[mapped_before] = mapped_after - end - - # Now, detect rings within the pairs. - # The proof is, if whatever was at position i was sent to position j, - # whatever was at position j cannot have stayed at j. - # By induction, there is a ring. - # Oh, and a piece of the proof is that the arrays have the same length. - # Trivially. Right. Hey, this is not an interview! - rings = [] - while moves.size > 0 - # i goes to j. j goes to (…). k goes to i. - ring = [] - pair = moves.shift - origin, target = pair - first_origin = origin - while target != first_origin - ring << origin - origin = target - target = moves[target] - moves.delete(origin) - end - ring << origin - rings << ring - end - # rings is of the form [[i,j,k], …] - - # Finally, we can register the moves. - # The idea is, if the whole ring moves instantaneously, - # no element outside of the ring changed position. - pairs = [] - rings.each do |ring| - orig_ring = ring.map { |i| [orig_before[i], orig_after[i]] } - ring_map = IndexMaps.new - len = ring.size - i = 0 - while i < len - ni = (i + 1) % len # next i - if ring[i] != ring[ni] - pairs << [ring_map.map(ring[i]), ring[ni], orig_ring[i][0], orig_ring[ni][1]] - end - ring_map.removal(ring[i]) - ring_map.addition(ring[ni]) - i += 1 - end - end - - pairing[:pairs] = pairs - - pairing - end - end diff --git a/lib/json-diff/v2/operation.rb b/lib/json-diff/v2/operation.rb deleted file mode 100644 index 1c2693e..0000000 --- a/lib/json-diff/v2/operation.rb +++ /dev/null @@ -1,53 +0,0 @@ -module JsonDiff - class v2 - # Convert a list of strings or numbers to an RFC6901 JSON pointer. - # http://tools.ietf.org/html/rfc6901 - def json_pointer(path) - return "" if path == [] - - escaped_path = path.map do |key| - if key.is_a?(String) - key.gsub('~', '~0') - .gsub('/', '~1') - else - key.to_s - end - end.join('/') - - "/#{escaped_path}" - end - - # Add a key to a JSON pointer. - def extend_json_pointer(pointer, key) - if pointer == '' - json_pointer([key]) - else - pointer + json_pointer([key]) - end - end - - def replace(path, before, after) - if before != nil - {'op' => 'replace', 'path' => path, 'was' => before, 'value' => after} - else - {'op' => 'replace', 'path' => path, 'value' => after} - end - end - - def add(path, value) - {'op' => 'add', 'path' => path, 'value' => value} - end - - def remove(path, value) - if value != nil - {'op' => 'remove', 'path' => path, 'was' => value} - else - {'op' => 'remove', 'path' => path} - end - end - - def move(source, target) - {'op' => 'move', 'from' => source, 'path' => target} - 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 From e41aec96c4f0b18315ba292105d5577ef3ff9a31 Mon Sep 17 00:00:00 2001 From: David Henry Date: Tue, 22 Sep 2020 15:06:03 +0100 Subject: [PATCH 5/5] Add filtering on replaced item --- lib/json-diff/v2/diff.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/json-diff/v2/diff.rb b/lib/json-diff/v2/diff.rb index a8c6975..927e924 100644 --- a/lib/json-diff/v2/diff.rb +++ b/lib/json-diff/v2/diff.rb @@ -104,19 +104,19 @@ def diff_array(before, after, path) 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) + 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) + 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) + changes << JsonDiff.replace(path, include_was(path) ? before : nil, after) if include_replace(path) end end end @@ -131,6 +131,11 @@ def include_moves(path) 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] @@ -142,3 +147,4 @@ def original_indices(path) end end end +