module Ecoportal module API module Common module Content module HashDiffPatch ID_KEYS = %w[id] META_KEYS = %w[id patch_ver] NO_CHANGES = "%not-changed!%" extend Content::DocHelpers class << self # The `patch data` is built as follows: # 1. detect changes that have occurred translate into one `operation` of `OP_TYPE`: # * `changed`: meaning that the object has changed (existed and has not been removed) # * `delete`: the object has been removed # * `new`: the object is new # 2. at the level of the target object of the model, the object is opened for change # with `id` and `operation` as follows: # # ```json # { # "id": "objectID", # "operation": "OP_TYPE", # "data": { # "patch_ver": "prev_patch_ver_+1", # "property": "value", # "...": "..." # } # } # ``` # 3. the `data` property holds the specific changes of the object # - the `patch_ver` (compulsory) is **incremental** (for data integrity) # - the properties that have changed # @note # * there should not be difference between `null` and `""` (empty string) # @param a [Hash] current hash model # @param b [Hash] previous hash model # @return [Hash] a `patch data` def patch_diff(a, b) case when b.is_a?(Hash) && !empty?(b) && empty?(a) patch_delete(b) when a.is_a?(Hash) && !empty?(a) && empty?(b) patch_new(a) when a.is_a?(Hash) && b.is_a?(Hash) patch_update(a, b) when any_array?(a, b) patch_data_array(a, b) else a end end private def equal_values(a, b) if a.is_a?(String) || b.is_a?(String) return true if a.to_s.strip.empty? && b.to_s.strip.empty? end a == b end # Compares `a` as charring changes of `b` # @return [Hash] patch data object with only changes def patch_data(a, b = nil, delete: false) {}.tap do |data_hash| if delete patch_ver = (a && a["patch_ver"]) || 1 data_hash["patch_ver"] = patch_ver next end a.each do |key, a_value| b_value = b[key] if b_has_key = b && b.key?(key) is_meta_key = META_KEYS.include?(key) skip_equals = b_has_key && equal_values(a_value, b_value) next if is_meta_key || skip_equals data_hash[key] = patch_diff(a_value, b_value) data_hash.delete(key) if data_hash[key] == NO_CHANGES end #if (data_hash.keys - ID_KEYS).empty? if (data_hash.keys - META_KEYS).empty? return NO_CHANGES else #patch_ver = (b && b["patch_ver"]) || 1 #data_hash["patch_ver"] = patch_ver if b && b.key?("patch_ver") data_hash["patch_ver"] = b["patch_ver"] elsif a && a.key?("patch_ver") data_hash["patch_ver"] = a["patch_ver"] end end end end def patch_delete(b) return NO_CHANGES unless b.is_a?(Hash) if id = get_id(b, exception: false) { "id" => id, "operation" => "deleted", "data" => patch_data(b, delete: true) } else nil end end def patch_new(a) return NO_CHANGES unless a.is_a?(Hash) if id = get_id(a, exception: false) { "id" => id, "operation" => "new", "data" => patch_data(a) } else a end end def patch_update(a, b) return NO_CHANGES unless a.is_a?(Hash) if id = get_id(a, exception: false) { "id" => id, "operation" => "changed", "data" => patch_data(a, b) }.tap do |update_hash| return nil unless update_hash["data"] != NO_CHANGES end else a end end def patch_data_array(a, b) original_b = b a ||= []; b ||= [] if !nested_array?(a, b) if a.length == b.length && (a & b).length == b.length if original_b NO_CHANGES else a end else a end else # array with nested elements a_ids = array_ids(a) b_ids = array_ids(b) del_ids = b_ids - a_ids oth_ids = b_ids & a_ids new_ids = a_ids - b_ids arr_delete = del_ids.map do |id| patch_delete(array_id_item(b, id)) end.compact arr_update = oth_ids.map do |id| patch_update(array_id_item(a, id), array_id_item(b, id)) end.compact arr_new = new_ids.map do |id| patch_new(array_id_item(a, id)) end.compact (arr_delete.concat(arr_update).concat(arr_new)).tap do |patch_array| # remove data with no `id` patch_array.reject! {|item| !item.is_a?(Hash)} return NO_CHANGES if patch_array.empty? end end end def nested_array?(*arr) case when arr.length > 1 arr.any? {|a| nested_array?(a)} when arr.length == 1 arr = arr.first arr.any? do |item| item.is_a?(Hash) && item.has_key?("patch_ver") end else false end end def any_array?(a, b) [a, b].any? {|item| item.is_a?(Array)} end def empty?(a) bool = !a bool ||= a.respond_to?(:empty?) && a.empty? end end end end end end end