class PhraseAppUpdater::Differ
Constants
- SEPARATOR
Public Class Methods
new(verbose: false)
click to toggle source
# File lib/phraseapp_updater/differ.rb, line 12 def initialize(verbose: false) @verbose = verbose end
Public Instance Methods
apply_diffs(hash, diffs)
click to toggle source
# File lib/phraseapp_updater/differ.rb, line 65 def apply_diffs(hash, diffs) deep_compact!(Hashdiff.patch!(hash, diffs)) end
resolve!(original:, primary:, secondary:)
click to toggle source
# File lib/phraseapp_updater/differ.rb, line 69 def resolve!(original:, primary:, secondary:) # To appropriately cope with type changes on either sides, flatten the # trees before calculating the difference and then expand afterwards. f_original = flatten(original) f_primary = flatten(primary) f_secondary = flatten(secondary) primary_diffs = Hashdiff.diff(f_original, f_primary) secondary_diffs = Hashdiff.diff(f_original, f_secondary) # However, flattening discards one critical piece of information: when we # have deleted or clobbered an entire prefix (subtree) from the original, # we want to consider this deletion atomic. If any of the changes is # cancelled, they must all be. Motivating example: # # original: { word: { one: "..", "many": ".." } } # primary: { word: { one: "..", "many": "..", "zero": ".." } } # secondary: { word: ".." } # would unexpectedly result in { word: { zero: ".." } }. # # Additionally calculate subtree prefixes that were deleted in `secondary`: secondary_deleted_prefixes = Hashdiff.diff(original, secondary, delimiter: SEPARATOR).lazy .select { |op, path, from, to| (op == "-" || op == "~") && from.is_a?(Hash) && !to.is_a?(Hash) } .map { |op, path, from, to| path } .to_a resolved_diffs = resolve_diffs(primary: primary_diffs, secondary: secondary_diffs, secondary_deleted_prefixes: secondary_deleted_prefixes) if @verbose STDERR.puts('Primary diffs:') primary_diffs.each { |d| STDERR.puts(d.inspect) } STDERR.puts('Secondary diffs:') secondary_diffs.each { |d| STDERR.puts(d.inspect) } STDERR.puts('Resolution:') resolved_diffs.each { |d| STDERR.puts(d.inspect) } end Hashdiff.patch!(f_original, resolved_diffs) expand(f_original) end
resolve_diffs(primary:, secondary:, secondary_deleted_prefixes:)
click to toggle source
Resolution strategy is that primary always wins in the event of a conflict
# File lib/phraseapp_updater/differ.rb, line 17 def resolve_diffs(primary:, secondary:, secondary_deleted_prefixes:) primary = primary.index_by { |op, path, from, to| path } secondary = secondary.index_by { |op, path, from, to| path } # As well as explicit conflicts, we want to make sure that deletions or # incompatible type changes to a `primary` key prevent addition of child # keys in `secondary`. Because input hashes are flattened, it's never # possible for a given path and its prefix to be in the same input. # For example, in: # # primary = [["+", "a", 1]] # secondary = [["+", "a.b", 2]] # # the secondary change is impossible to perform on top of the primary, and # must be blocked. # # This applies in reverse: prefixes of paths in `p` need to be available # as hashes, so must not appear as terminals in `s`: # # primary = [["+", "a.b", 2]] # secondary = [["+", "a", 1]] primary_prefixes = primary.keys.flat_map { |p| path_prefixes(p) }.to_set # Remove conflicting entries from secondary, recording incompatible # changes. path_conflicts = [] secondary.delete_if do |path, diff| if primary_prefixes.include?(path) || primary.keys.any? { |pk| path.start_with?(pk) } path_conflicts << path unless primary.has_key?(path) && diff == primary[path] true else false end end # For all path conflicts matching secondary_deleted_prefixes, additionally # remove other changes with the same prefix. prefix_conflicts = secondary_deleted_prefixes.select do |prefix| path_conflicts.any? { |path| path.start_with?(prefix) } end secondary.delete_if do |path, diff| prefix_conflicts.any? { |prefix| path.start_with?(prefix) } end primary.values + secondary.values end
restore_deletions(current, previous)
click to toggle source
Prefer everything in current except deletions, which are restored from previous if available
# File lib/phraseapp_updater/differ.rb, line 120 def restore_deletions(current, previous) current.deep_merge(previous) end
Private Instance Methods
expand(flat_hash)
click to toggle source
# File lib/phraseapp_updater/differ.rb, line 138 def expand(flat_hash) flat_hash.each_with_object({}) do |(key, value), root| path = key.split(SEPARATOR) leaf_key = path.pop leaf = path.inject(root) do |node, path_key| node[path_key] ||= {} end raise ArgumentError.new("Type conflict in flattened hash expand: expected no key at #{key}") if leaf.has_key?(leaf_key) leaf[leaf_key] = value end end
flatten(hash, prefix = nil, acc = {})
click to toggle source
# File lib/phraseapp_updater/differ.rb, line 126 def flatten(hash, prefix = nil, acc = {}) hash.each do |k, v| k = "#{prefix}#{SEPARATOR}#{k}" if prefix if v.is_a?(Hash) flatten(v, k, acc) else acc[k] = v end end acc end
path_prefixes(path_string)
click to toggle source
# File lib/phraseapp_updater/differ.rb, line 150 def path_prefixes(path_string) path = path_string.split(SEPARATOR) parents = [] path.inject do |acc, el| parents << acc "#{acc}#{SEPARATOR}#{el}" end parents end